trantor 0.17.52 → 0.17.55

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + context-handoff for independent AI coding agents (Claude, Codex, Gemini, …)",
9
- "version": "0.17.52"
9
+ "version": "0.17.54"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "trantor",
14
14
  "source": "./",
15
15
  "description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, GLM, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
16
- "version": "0.17.52",
16
+ "version": "0.17.55",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.52",
3
+ "version": "0.17.55",
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/README.md CHANGED
@@ -39,6 +39,35 @@ claude plugin marketplace add sashabogi/trantor
39
39
  claude plugin install trantor
40
40
  ```
41
41
 
42
+ **Kimi Code CLI as orchestrator** (same capabilities — the `relay_*` MCP tools, the crew/handoff/
43
+ research skills, and the full hook set: session registration, focus cards, todo mirroring,
44
+ heartbeats, inbox delivery, handoff/baton pass, sub-agent cards):
45
+
46
+ 1. Register the relay MCP server in `~/.kimi-code/mcp.json`:
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "relay": {
52
+ "command": "node",
53
+ "args": ["<absolute-path-to-trantor>/mcp.mjs"],
54
+ "env": { "RELAY_URL": "http://127.0.0.1:4477", "RELAY_AGENT": "kimi" },
55
+ "startupTimeoutMs": 15000,
56
+ "toolTimeoutMs": 150000
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ 2. In the Kimi TUI: `/plugins install <absolute-path-to-trantor>` (or the GitHub URL), then
63
+ `/reload`, then start a new session.
64
+
65
+ Notes: Kimi plugin installs are **snapshots** (`~/.kimi-code/plugins/managed/trantor/`) — after
66
+ updating trantor, re-run `/plugins install` to refresh the skills/hooks. The MCP entry above always
67
+ runs your live checkout, so the relay server itself never goes stale. Invoke the skills with
68
+ `/skill:crew`, `/skill:handoff`, `/skill:research`. Set `TRANTOR_DEBUG_HOOKS=1` on the `kimi`
69
+ process to dump raw hook payloads to `~/.agent-bus/kimi-hook-debug.jsonl`.
70
+
42
71
  That's it. (Prefer source? `git clone https://github.com/sashabogi/trantor && cd trantor &&
43
72
  npm install && bash deploy/setup.sh` — identical result.)
44
73
 
package/bin/adopt.mjs ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // trantor adopt — graduate a project from the machine-local hub to a remote hub, in ONE command.
3
+ //
4
+ // trantor adopt <project> [--hub <url>] [--dry] [--force]
5
+ //
6
+ // The crm-platform lesson: a new project is born unpinned, lives on the local hub (by design —
7
+ // TDD §12.1's fallback), and moving it to the shared hub was three separate ceremonies (enroll
8
+ // identities, migrate data, write the pin) spread across two machines. This collapses them:
9
+ //
10
+ // 1. read the project's rows off the LOCAL hub state (tasks/events/messages)
11
+ // 2. enroll this machine's identities for the project on the target hub (owner-signed invites):
12
+ // the orchestrator (<host>:<project>) as owner, every existing seat key as write
13
+ // 3. POST /import (owner-signed) — the hub merges, remapping colliding card ids itself
14
+ // 4. verify the count round-trip, THEN write the routing pin
15
+ //
16
+ // No ssh, no direct Postgres access: the hub's /import endpoint is the migration surface.
17
+ // Live sessions keep their old routing until restarted — adopt SAYS so rather than pretending.
18
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { homedir } from "node:os";
21
+ import { hostId } from "../lib/project.mjs";
22
+ import { loadOrCreate, signRequest } from "../lib/identity.mjs";
23
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
24
+
25
+ const argv = process.argv.slice(2);
26
+ const PROJECT = argv.find(a => !a.startsWith("--")) || "";
27
+ const arg = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
28
+ const has = (k) => argv.includes(`--${k}`);
29
+ if (!PROJECT) { console.error("usage: trantor adopt <project> [--hub <url>] [--dry] [--force]"); process.exit(1); }
30
+
31
+ const BUS_DIR = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
32
+ const CONFIG_PATH = join(BUS_DIR, "config.json");
33
+ let config = {}; try { config = JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch {}
34
+ const LOCAL = config.url || "http://127.0.0.1:4477";
35
+
36
+ // target: --hub wins; else the hub most of the fleet already lives on
37
+ const pinCounts = {};
38
+ for (const u of Object.values(config.hubs || {})) if (!/127\.0\.0\.1|localhost/.test(u)) pinCounts[u] = (pinCounts[u] || 0) + 1;
39
+ const TARGET = arg("hub") || Object.entries(pinCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "";
40
+ if (!TARGET) { console.error("no remote hub known — pass --hub <url> (no non-local pins exist to infer one from)"); process.exit(1); }
41
+ if ((config.hubs || {})[PROJECT] === TARGET) { console.log(`${PROJECT} is already pinned to ${TARGET} — nothing to do.`); process.exit(0); }
42
+
43
+ // 1. the project's rows, straight from the local hub's state file (full fidelity, no pagination)
44
+ const statePath = process.env.RELAY_STATE || join(BUS_DIR, "bus.json");
45
+ let local = {}; try { local = JSON.parse(readFileSync(statePath, "utf8")); } catch {}
46
+ const tasks = (local.tasks || []).filter(t => t.project === PROJECT);
47
+ const events = (local.events || []).filter(e => e.project === PROJECT);
48
+ const messages = (local.messages || []).filter(m => (m.project || "") === PROJECT);
49
+ const livePeers = Object.entries(local.peers || {}).filter(([, p]) => p.project === PROJECT && Date.now() - (p.lastSeen || 0) < 5 * 60 * 1000);
50
+
51
+ const owner = String(config.ownerIdentity || "");
52
+ if (!owner) { console.error("config.ownerIdentity is not set — enrolments need an owner to sign invites"); process.exit(1); }
53
+
54
+ // identities: the orchestrator as owner + every seat key that already exists for this project
55
+ const safe = (s) => s.replace(/[^A-Za-z0-9_.-]/g, "_");
56
+ const keyFiles = (() => { try { return readdirSync(join(BUS_DIR, "keys")); } catch { return []; } })();
57
+ const orchestrator = `${hostId()}:${PROJECT}`;
58
+ const seats = keyFiles
59
+ .filter(f => f.endsWith(`_${safe(PROJECT)}.json`))
60
+ .map(f => f.replace(/\.json$/, "").replace(`_${safe(PROJECT)}`, `:${PROJECT}`))
61
+ .filter(n => n !== safe(orchestrator).replace(`_${safe(PROJECT)}`, `:${PROJECT}`) && n !== orchestrator);
62
+
63
+ console.log(`adopt : ${PROJECT}`);
64
+ console.log(`from : ${LOCAL} (${tasks.length} cards · ${events.length} events · ${messages.length} messages)`);
65
+ console.log(`to : ${TARGET}`);
66
+ console.log(`enroll : ${orchestrator} (owner)${seats.length ? ` + ${seats.join(", ")} (write)` : ""}`);
67
+ if (livePeers.length) console.log(`⚠ LIVE : ${livePeers.map(([s]) => s).join(", ")} — they keep the OLD routing until restarted`);
68
+ if (has("dry")) { console.log("\n[dry run] nothing changed."); process.exit(0); }
69
+
70
+ const ownerId = loadOrCreate(owner, "human");
71
+ async function enroll(name, role) {
72
+ const invBody = JSON.stringify({ scopes: [{ project: PROJECT, role }], ttlSec: 600 });
73
+ const invSig = signRequest(ownerId, { method: "POST", path: "/invite", body: invBody });
74
+ const inv = await (await fetch(`${TARGET}/invite`, { method: "POST", headers: { "content-type": "application/json", ...invSig }, body: invBody, signal: AbortSignal.timeout(8000) })).json();
75
+ if (!inv.token) throw new Error(`invite for ${name}: ${inv.error || "no token"}`);
76
+ const id = loadOrCreate(name, "agent");
77
+ const body = JSON.stringify({ token: inv.token, name, pubkey: id.pubkey, kind: "agent" });
78
+ const sig = signRequest(id, { method: "POST", path: "/enroll", body });
79
+ const r = await (await fetch(`${TARGET}/enroll`, { method: "POST", headers: { "content-type": "application/json", ...sig }, body, signal: AbortSignal.timeout(8000) })).json();
80
+ if (!r.ok) throw new Error(`enroll ${name}: ${r.error || "failed"}`);
81
+ console.log(` ✓ enrolled ${name} (${role})`);
82
+ }
83
+
84
+ try {
85
+ await enroll(orchestrator, "owner");
86
+ for (const s of seats) await enroll(s, "write");
87
+
88
+ const imp = await sfetchJson(`${TARGET}/import`, {
89
+ identity: ownerId,
90
+ payload: { project: PROJECT, tasks, events, messages, by: owner, force: has("force") },
91
+ signal: AbortSignal.timeout(60000),
92
+ });
93
+ const impJson = await imp.json();
94
+ if (!impJson.ok) throw new Error(`import: ${impJson.error || imp.status}${impJson.existing ? ` (${impJson.existing} cards already there — --force to merge anyway)` : ""}`);
95
+ console.log(`imported : ${impJson.tasks} cards · ${impJson.events} events · ${impJson.messages} messages${impJson.remapped ? ` · ${impJson.remapped} card id(s) remapped` : ""}`);
96
+
97
+ // verify BEFORE pinning — a pin pointing at a hub that doesn't have the data is a data outage
98
+ const check = await (await sfetchJson(`${TARGET}/tasks?project=${encodeURIComponent(PROJECT)}`, { method: "GET", identity: ownerId })).json();
99
+ const remoteCount = (check.tasks || []).length;
100
+ if (remoteCount < tasks.length) throw new Error(`verify: target has ${remoteCount} cards, local has ${tasks.length} — NOT pinning`);
101
+ console.log(`verified : ${remoteCount} cards on target`);
102
+
103
+ config.hubs = config.hubs || {};
104
+ config.hubs[PROJECT] = TARGET;
105
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
106
+ console.log(`pinned : ${PROJECT} → ${TARGET}`);
107
+ if (livePeers.length) {
108
+ console.log(`\n⚠ live sessions still route to the OLD hub until restarted:`);
109
+ for (const [s] of livePeers) console.log(` ${s}`);
110
+ console.log(` crew seats: trantor down && trantor up · Claude sessions: restart them when convenient.`);
111
+ }
112
+ console.log(`\n✓ adopted. New sessions on ${PROJECT} land on ${TARGET}.`);
113
+ } catch (e) {
114
+ console.error(`\n✗ adopt failed: ${e.message}`);
115
+ console.error("nothing was pinned — routing is unchanged.");
116
+ process.exit(1);
117
+ }
package/bin/cli.mjs CHANGED
@@ -25,7 +25,36 @@ switch (cmd) {
25
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
26
  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;
27
27
  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;
28
- case "hub": run("hub.mjs"); break;
28
+ case "hub": {
29
+ const sub = args[0];
30
+ // Per-project hub routing (TDD §12.1): a project lives on exactly ONE hub; codependent
31
+ // projects MUST share one. The mapping lives in ~/.agent-bus/config.json `hubs`.
32
+ if (sub === "list" || sub === "set" || sub === "unset") {
33
+ const { readConfig, setProjectHub, unsetProjectHub, resolveProject, resolveHub, DEFAULT_HUB_URL } = await import(join(ROOT, "lib/project.mjs"));
34
+ if (sub === "list") {
35
+ const cfg = readConfig();
36
+ const here = resolveProject();
37
+ console.log(`global default: ${cfg.url || DEFAULT_HUB_URL}${cfg.url ? "" : " (built-in)"}`);
38
+ const hubs = cfg.hubs && typeof cfg.hubs === "object" ? Object.entries(cfg.hubs) : [];
39
+ if (!hubs.length) console.log("no per-project pins — every project uses the global default");
40
+ for (const [p, u] of hubs) console.log(`${p === here ? "*" : " "} ${p} → ${u}`);
41
+ console.log(`effective hub for this project (${here}): ${resolveHub(here)}`);
42
+ } else if (sub === "set") {
43
+ const [, project, url] = args;
44
+ if (!project || !url) { console.error("usage: trantor hub set <project> <url>"); process.exit(1); }
45
+ try { setProjectHub(project, url); }
46
+ catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
47
+ console.log(`${project} → ${String(url).replace(/\/+$/, "")} (pinned in ~/.agent-bus/config.json)`);
48
+ } else {
49
+ const project = args[1];
50
+ if (!project) { console.error("usage: trantor hub unset <project>"); process.exit(1); }
51
+ const had = unsetProjectHub(project);
52
+ console.log(had ? `${project} unpinned — falls back to the global default` : `${project} had no per-project pin`);
53
+ }
54
+ break;
55
+ }
56
+ run("hub.mjs"); break;
57
+ }
29
58
  case "watch": run("bin/relay-watch.mjs"); break;
30
59
  case "catchup": run("bin/catchup.mjs"); break;
31
60
  case "agents": run("bin/agents.mjs"); break;
@@ -37,6 +66,69 @@ switch (cmd) {
37
66
  case "balances": case "balance": case "credits": run("bin/balances.mjs"); break;
38
67
  case "recost": run("bin/recost.mjs"); break;
39
68
  case "handoff": run("bin/baton.mjs"); break;
69
+ case "adopt": run("bin/adopt.mjs"); break;
70
+ case "summarize": run("bin/summarize.mjs"); break;
71
+ case "identity": {
72
+ const { load, publicView, generate, keyPath } = await import(join(ROOT, "lib/identity.mjs"));
73
+ const sub = args[0], name = args[1] || "human";
74
+ if (sub === "show") {
75
+ const id = load(name);
76
+ if (!id) { console.error(`No identity found for "${name}".`); process.exit(1); }
77
+ console.log(JSON.stringify(publicView(id), null, 2));
78
+ } else if (sub === "rotate") {
79
+ const { writeFileSync, chmodSync, renameSync, mkdirSync } = await import("node:fs");
80
+ const { randomBytes } = await import("node:crypto");
81
+ const { pubkey, privkey } = generate();
82
+ const nId = { name: String(name), kind: "human", pubkey, privkey, createdAt: Date.now() };
83
+ const f = keyPath(name);
84
+ mkdirSync(join(f, ".."), { recursive: true, mode: 0o700 });
85
+ try { chmodSync(join(f, ".."), 0o700); } catch {}
86
+ const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
87
+ writeFileSync(tmp, JSON.stringify(nId), { mode: 0o600 });
88
+ renameSync(tmp, f);
89
+ chmodSync(f, 0o600);
90
+ console.log(JSON.stringify({ name: String(name), pubkey, rotated: true }, null, 2));
91
+ } else {
92
+ console.error("usage: trantor identity <show|rotate> [name]");
93
+ process.exit(1);
94
+ }
95
+ break;
96
+ }
97
+ case "invite": {
98
+ const { loadOrCreate, signRequest } = await import(join(ROOT, "lib/identity.mjs"));
99
+ const nameIdx = args.indexOf("--name"), scopeIdx = args.indexOf("--scope");
100
+ const name = nameIdx >= 0 ? args[nameIdx + 1] : "";
101
+ const scopeRaw = scopeIdx >= 0 ? (args[scopeIdx + 1] || "") : "";
102
+ if (!name || !scopeRaw) { console.error("usage: trantor invite --name <name> --scope <project>:<role>"); process.exit(1); }
103
+ const [project, role = "write"] = scopeRaw.split(":");
104
+ let hub = "http://127.0.0.1:4477";
105
+ try { hub = JSON.parse(readFileSync(join(process.env.HOME || "", ".agent-bus", "config.json"), "utf8")).url || hub; } catch {}
106
+ const id = loadOrCreate("admin", "human");
107
+ const payload = { scopes: [{ project, role }], ttlSec: 86400 };
108
+ const body = JSON.stringify(payload);
109
+ const sig = signRequest(id, { method: "POST", path: "/invite", body });
110
+ const r = await fetch(`${hub}/invite`, { method: "POST", headers: { "content-type": "application/json", ...sig }, body });
111
+ const j = await r.json();
112
+ if (r.ok) console.log(`Invite token: ${j.token}\nShare this with the new member: trantor enroll ${j.token}`);
113
+ else console.error(`Invite failed: ${j.error || r.statusText}`);
114
+ break;
115
+ }
116
+ case "enroll": {
117
+ const token = args[0];
118
+ if (!token) { console.error("usage: trantor enroll <token>"); process.exit(1); }
119
+ let hub = "http://127.0.0.1:4477";
120
+ try { hub = JSON.parse(readFileSync(join(process.env.HOME || "", ".agent-bus", "config.json"), "utf8")).url || hub; } catch {}
121
+ const { loadOrCreate, signRequest } = await import(join(ROOT, "lib/identity.mjs"));
122
+ const id = loadOrCreate("human", "human");
123
+ const payload = { token, name: "human", pubkey: id.pubkey, kind: "human" };
124
+ const body = JSON.stringify(payload);
125
+ const sig = signRequest(id, { method: "POST", path: "/enroll", body });
126
+ const r = await fetch(`${hub}/enroll`, { method: "POST", headers: { "content-type": "application/json", ...sig }, body });
127
+ const j = await r.json();
128
+ if (r.ok) console.log(`Enrolled! Pubkey: ${id.pubkey.slice(0, 16)}…`);
129
+ else console.error(`Enrollment failed: ${j.error || r.statusText}`);
130
+ break;
131
+ }
40
132
  case "ui": {
41
133
  let url = "http://127.0.0.1:4477";
42
134
  try { url = JSON.parse(readFileSync(join(process.env.HOME || "", ".agent-bus", "config.json"), "utf8")).url || url; } catch {}
@@ -68,6 +160,7 @@ switch (cmd) {
68
160
  trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
69
161
  trantor advise ask the Advisor directly (JSON on stdin; --demo to see it)
70
162
  trantor hub run the hub in the foreground (setup installs it as a service instead)
163
+ …or manage per-project hub pins: hub list · hub set <project> <url> · hub unset <project>
71
164
  trantor watch live bus feed in the terminal
72
165
 
73
166
  Claude Code plugin (the orchestrator side):
@@ -12,7 +12,10 @@ import { execSync, spawnSync } from "node:child_process";
12
12
  import { readFileSync, existsSync, appendFileSync } from "node:fs";
13
13
  import { join, basename } from "node:path";
14
14
  import { homedir } from "node:os";
15
- import { resolveProject } from "../lib/project.mjs";
15
+ import { resolveProject, resolveHub } from "../lib/project.mjs";
16
+ import { loadOrCreate } from "../lib/identity.mjs";
17
+ import { signedHeaders } from "../lib/signed-fetch.mjs";
18
+ import { ensureEnrolled } from "../lib/enroll.mjs";
16
19
 
17
20
  const AGENT = process.argv[2];
18
21
  const DIR = process.argv[3] || process.cwd();
@@ -22,14 +25,27 @@ const DIR = process.argv[3] || process.cwd();
22
25
  // fork the host's "builtbetter.ai" into a separate "builtbetter" lane.
23
26
  const PROJ = process.env.RELAY_PROJECT || resolveProject(DIR);
24
27
  const SESSION = `${AGENT}:${PROJ}`;
28
+ // One keypair per seat, so `deepseek:crebral` and `deepseek:trantor` are genuinely different
29
+ // identities on the bus rather than one shared string label.
30
+ const identity = loadOrCreate(SESSION, "agent");
25
31
  if (!AGENT) { console.error("usage: crew-runner.mjs <agent> [project-dir]"); process.exit(1); }
26
32
 
33
+ // Per-project routing (TDD §12.1): a seat MUST reach the same hub as the project it serves. Reading
34
+ // only the global default sent seats on a migrated project to the local hub while their orchestrator
35
+ // talked to the remote one — the crew would look alive and record onto a different board entirely.
27
36
  function hubUrl() {
28
37
  if (process.env.RELAY_URL) return process.env.RELAY_URL;
29
- try { const u = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")).url; if (u) return u; } catch {}
38
+ try { return resolveHub(PROJ); } catch {}
30
39
  return "http://127.0.0.1:4477";
31
40
  }
32
41
  const HUB = hubUrl();
42
+ // On an authenticated hub a freshly-created seat keypair is an UNKNOWN identity, so every call 401s
43
+ // and the seat goes silently quiet (we fail open by design). Self-enrol first, using the operator's
44
+ // owner key to mint a short-lived project-scoped invite the seat immediately spends.
45
+ const enrolment = await ensureEnrolled(HUB, identity, PROJ);
46
+ if (!enrolment.ok && enrolment.reason !== "hub-unreachable") {
47
+ console.log(`\x1b[33m[runner]\x1b[0m not enrolled on ${HUB} (${enrolment.reason}) — cards may not record`);
48
+ }
33
49
  process.on("uncaughtException", (e) => { console.log(`\x1b[31m[runner] UNCAUGHT: ${e?.stack || e}\x1b[0m`); });
34
50
  process.on("unhandledRejection", (e) => { console.log(`\x1b[31m[runner] UNHANDLED REJECTION: ${e?.stack || e}\x1b[0m`); });
35
51
  const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${s}`);
@@ -46,7 +62,11 @@ async function api(path, body) {
46
62
  const opts = body
47
63
  ? { method: "POST", headers: { "content-type": "application/json", connection: "close" }, body: JSON.stringify(body) }
48
64
  : { headers: { connection: "close" } }; // fresh socket per call — long-polls on stale keep-alive sockets reset
49
- const r = await fetch(HUB + path, opts);
65
+ // Sign as THIS seat. Unsigned calls are 401 on an enforce hub, and because the runner fails open
66
+ // that shows up as a seat that quietly records nothing rather than one that errors.
67
+ const url = HUB + path;
68
+ const sig = signedHeaders(identity, url, opts);
69
+ const r = await fetch(url, { ...opts, headers: { ...opts.headers, ...sig } });
50
70
  return r.json();
51
71
  }
52
72
 
@@ -59,7 +79,12 @@ const CMUX_BIN = process.env.CMUX_BIN
59
79
  const inCmux = () => !!process.env.CMUX_SURFACE_ID;
60
80
  function cmuxStatus(value, color, icon = "robot") {
61
81
  if (!inCmux()) return;
62
- try { spawnSync(CMUX_BIN, ["set-status", "trantor", value, "--color", color, "--icon", icon], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
82
+ // Label with the REAL seat identity, not a literal. This was hardcoded to "trantor", so every seat
83
+ // in every project reported under one name — four different agents (and their duplicates) rendered
84
+ // identically in the sidebar, which is why a runner leak looked like mystery sessions instead of
85
+ // obvious duplicates. Note this is the DISPLAY path; two previous fixes to the crossed-label
86
+ // symptom both landed on the *bus* identity and never touched this line.
87
+ try { spawnSync(CMUX_BIN, ["set-status", SESSION, value, "--color", color, "--icon", icon], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
63
88
  }
64
89
  function cmuxLog(message, level = "info") {
65
90
  if (!inCmux()) return;
@@ -129,7 +154,7 @@ async function reportFailure(exit, trigger) {
129
154
  const reason = classifyFailure(exit, lastErrText);
130
155
  const down = consecFails >= 2;
131
156
  const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
132
- await api("/register", { session: SESSION, project: PROJ, status }).catch(() => {});
157
+ await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
133
158
  const hint = reason === "exhausted" ? " — needs `trantor swap`"
134
159
  : reason === "auth" ? " — check credentials"
135
160
  : reason === "missing-cli" ? " — CLI not on PATH" : "";
@@ -144,7 +169,7 @@ async function reportFailure(exit, trigger) {
144
169
  async function reportHealthy() {
145
170
  if (consecFails === 0) return; // already healthy — don't spam
146
171
  consecFails = 0;
147
- await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}` }).catch(() => {});
172
+ await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL }).catch(() => {});
148
173
  await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ }).catch(() => {});
149
174
  cmuxStatus("ok", "#14b8a6", "check");
150
175
  }
@@ -184,7 +209,7 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
184
209
 
185
210
  // ---- main loop ----
186
211
  const KICKOFF = process.env.CREW_KICKOFF ||
187
- `You just joined. 1) relay_send to "all": "${AGENT} reporting ready for a contract". 2) relay_inbox — if a contract for you is already waiting, do it now per the Rules. 3) End your turn.\n\n${RULES}`;
212
+ `You just joined (your arrival was already announced on the bus). 1) relay_inbox — if a contract for you is already waiting, do it now per the Rules. 2) End your turn.\n\n${RULES}`;
188
213
 
189
214
  let LESSONS = "";
190
215
  async function loadLessons() {
@@ -199,7 +224,19 @@ async function loadLessons() {
199
224
  // start cursor at the CURRENT tip so we don't replay history
200
225
  let cursor = 0;
201
226
  try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
202
- await api("/register", { session: SESSION, project: PROJ, status: "crew member booting" }).catch(() => {});
227
+ await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL }).catch(() => {});
228
+ // Announce runner-side, signed as THIS seat. Asking the seat to announce itself sent glm's hello
229
+ // out under deepseek's identity whenever opencode seats shared one MCP daemon (lesson on the bus,
230
+ // 2026-07-29): the runner process is per-seat by construction, so its signature cannot be borrowed.
231
+ try {
232
+ const { sfetchJson } = await import("../lib/signed-fetch.mjs");
233
+ const { loadOrCreate } = await import("../lib/identity.mjs");
234
+ await sfetchJson(`${HUB}/send`, {
235
+ identity: loadOrCreate(SESSION, "agent"),
236
+ payload: { from: SESSION, to: "all", project: PROJ, text: `${AGENT} reporting — ready for a contract${MODEL ? ` (${MODEL})` : ""}` },
237
+ signal: AbortSignal.timeout(2500),
238
+ });
239
+ } catch {}
203
240
 
204
241
  let pendingBcast = [];
205
242
  const ec0 = runTurn(KICKOFF + LESSONS, true, "kickoff");
package/bin/crew.sh CHANGED
@@ -266,6 +266,10 @@ AGENT=""; MODEL=""
266
266
  resolve_spec() {
267
267
  local SPEC="$1" FIELD
268
268
  AGENT="${SPEC%%:*}"; MODEL=""
269
+ # Replace, never stack. Reaps here — as a plain statement — because the alternative home, RUN_CMD,
270
+ # is always invoked as `cmd="$(RUN_CMD)"`, and command substitution would swallow run()'s output
271
+ # into the launcher string. AGENT is set above and DIR is fixed, so this is the earliest safe point.
272
+ reap_seat
269
273
  FIELD=""; [ "$SPEC" != "$AGENT" ] && FIELD="${SPEC#*:}"
270
274
  [ "$AGENT" = "openrouter" ] && [ -z "$FIELD" ] && FIELD="openrouter"
271
275
  if [ -n "$FIELD" ]; then
@@ -277,6 +281,29 @@ resolve_spec() {
277
281
  esac
278
282
  fi
279
283
  }
284
+ # Kill any runner ALREADY serving this exact agent+project before starting another.
285
+ #
286
+ # Without this, `trantor up <agent>` ADDS a runner instead of replacing one — and every duplicate
287
+ # long-polls the SAME inbox, so a single contract wakes N runners and each burns a full CLI turn on
288
+ # the same card, racing to edit the same files. Observed 2026-07-29 on one project: codex x2, glm x3,
289
+ # deepseek x3; three byte-identical "#3931 doing" broadcasts seconds apart; an OpenRouter key at $55
290
+ # against a $20 cap; a seat blowing its context window at 362k tokens. It read as heavy usage. It was
291
+ # duplicated usage.
292
+ #
293
+ # Scoped to agent+project on purpose: re-firing one seat must not disturb its siblings, and a crew in
294
+ # another project is never ours to touch.
295
+ reap_seat() {
296
+ local pid
297
+ # Anchored: the runner's argv ends with DIR, and without the $ a reap for …/proj also matches a
298
+ # runner in …/proj2 — killing a SIBLING project's seat on a directory-name prefix collision.
299
+ for pid in $(pgrep -f "crew-runner\.mjs $AGENT $DIR"'$' 2>/dev/null); do
300
+ run "kill -9 $pid 2>/dev/null"
301
+ done
302
+ }
303
+ # RUN_CMD must stay PURE: every caller invokes it as `cmd="$(RUN_CMD)"`, i.e. command substitution,
304
+ # which captures ALL stdout. Anything that prints — including run()'s `[dry]` echo — would be swallowed
305
+ # into the command string and end up inside the launcher. The reap therefore lives in resolve_spec(),
306
+ # which every spawn path calls as a plain statement immediately before this.
280
307
  RUN_CMD() { printf 'cd %q && CREW_MODEL=%q RELAY_PROJECT=%q node %q %q %q' "$DIR" "$MODEL" "$PROJ" "$BUS_DIR/bin/crew-runner.mjs" "$AGENT" "$DIR"; }
281
308
 
282
309
  # ── tmux spawn: ONE session `trantor:$PROJ`, one named pane per seat, one Terminal window attached ────
@@ -368,12 +395,17 @@ spawn_cmux() { # $@ = specs
368
395
  echo " ~/.config/cmux/cmux.json (cmux auto-reloads). —"
369
396
  spawn_cmux_applescript "$@"; return
370
397
  fi
371
- local first=1 SPEC wsid="" surf="" i=0
372
- local dirs=(down right down right down right down right)
398
+ # Grid tiling: COLS = ceil(sqrt(N)) → 2 seats side-by-side, 4 = 2×2, 6 = 3×2. Row 0 is built with
399
+ # RIGHT splits off the previous column; each later row splits DOWN from the pane directly above it.
400
+ # Every split TARGETS a recorded surface id (--surface) — never "whatever pane happens to be focused",
401
+ # which is what produced the old staircase layout.
402
+ local N=$# COLS=1; while [ $(( COLS * COLS )) -lt "$N" ]; do COLS=$(( COLS + 1 )); done
403
+ local SPEC wsid="" surf="" i=0
404
+ local surfs=()
373
405
  for SPEC in "$@"; do
374
406
  resolve_spec "$SPEC"
375
407
  local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
376
- if [ "$first" = "1" ]; then
408
+ if [ "$i" = "0" ]; then
377
409
  if [ "$DRY" = "1" ]; then
378
410
  echo "[dry] cmux: new-workspace (cwd $DIR) --command 'bash $launcher' → rename 'trantor:$PROJ'"
379
411
  wsid="%DRYWS"; surf="%DRYT0"
@@ -384,16 +416,19 @@ spawn_cmux() { # $@ = specs
384
416
  surf="$(_cmux list-pane-surfaces --workspace "$wsid" --id-format uuids --json 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));const a=o.surfaces||o.panes||o||[];const s=Array.isArray(a)?a[0]:null;process.stdout.write((s&&(s.id||s.surface_id))||"")}catch(e){}})')"
385
417
  fi
386
418
  record_state "$PROJ" "cmuxws" "__ws__" "$wsid"
387
- first=0
388
419
  else
389
- local dir="${dirs[$(( i % ${#dirs[@]} ))]}"
420
+ local dir target
421
+ if [ $(( i / COLS )) = "0" ]; then dir="right"; target="${surfs[$(( i - 1 ))]}"
422
+ else dir="down"; target="${surfs[$(( i - COLS ))]}"; fi
390
423
  if [ "$DRY" = "1" ]; then
391
- echo "[dry] cmux: new-split $dir + send 'bash $launcher'"; surf="%DRYT$i"
424
+ echo "[dry] cmux: new-split $dir --surface ${target:-<focused>} + send 'bash $launcher'"; surf="%DRYT$i"
392
425
  else
393
- surf="$(_cmux new-split "$dir" --workspace "$wsid" --id-format uuids --json 2>/dev/null | _cmux_surf_json)"
426
+ local tflag=(); [ -n "$target" ] && [ "${target#\%DRY}" = "$target" ] && tflag=(--surface "$target")
427
+ surf="$(_cmux new-split "$dir" --workspace "$wsid" "${tflag[@]}" --id-format uuids --json 2>/dev/null | _cmux_surf_json)"
394
428
  [ -n "$surf" ] && { _cmux send --surface "$surf" "bash $launcher" >/dev/null 2>&1; _cmux send-key --surface "$surf" enter >/dev/null 2>&1; }
395
429
  fi
396
430
  fi
431
+ surfs+=("$surf")
397
432
  record_state "$PROJ" "cmux" "$AGENT" "$surf"
398
433
  echo " → $AGENT seat in cmux workspace ($PROJ)"
399
434
  i=$(( i + 1 ))
@@ -404,12 +439,15 @@ spawn_cmux() { # $@ = specs
404
439
 
405
440
  # AppleScript fallback (cmux control socket off): same one-tab-per-project layout, minus native sidebar status.
406
441
  spawn_cmux_applescript() { # $@ = specs
407
- local first=1 SPEC tabid="" termid="" i=0
408
- local dirs=(down right down right down right down right)
442
+ # Same grid math as spawn_cmux: COLS = ceil(sqrt(N)); row 0 splits RIGHT off the previous column,
443
+ # later rows split DOWN from the terminal directly above — targeted by stable terminal id.
444
+ local N=$# COLS=1; while [ $(( COLS * COLS )) -lt "$N" ]; do COLS=$(( COLS + 1 )); done
445
+ local SPEC tabid="" termid="" i=0
446
+ local terms=()
409
447
  for SPEC in "$@"; do
410
448
  resolve_spec "$SPEC"
411
449
  local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
412
- if [ "$first" = "1" ]; then
450
+ if [ "$i" = "0" ]; then
413
451
  if [ "$DRY" = "1" ]; then
414
452
  echo "[dry] cmux(AppleScript): new tab (trantor:$PROJ) + run 'bash $launcher'"; tabid="%DRYTAB"; termid="%DRYT0"
415
453
  else
@@ -431,11 +469,12 @@ OSA
431
469
  tabid="${out%%|*}"; termid="${out##*|}"
432
470
  fi
433
471
  record_state "$PROJ" "cmuxws" "__ws__" "$tabid"
434
- first=0
435
472
  else
436
- local dir="${dirs[$(( i % ${#dirs[@]} ))]}"
473
+ local dir target
474
+ if [ $(( i / COLS )) = "0" ]; then dir="right"; target="${terms[$(( i - 1 ))]}"
475
+ else dir="down"; target="${terms[$(( i - COLS ))]}"; fi
437
476
  if [ "$DRY" = "1" ]; then
438
- echo "[dry] cmux(AppleScript): split $dir + run 'bash $launcher'"; termid="%DRYT$i"
477
+ echo "[dry] cmux(AppleScript): split $dir from ${target:-<focused>} + run 'bash $launcher'"; termid="%DRYT$i"
439
478
  else
440
479
  termid="$(osascript 2>/dev/null <<OSA
441
480
  tell application "cmux"
@@ -446,7 +485,12 @@ tell application "cmux"
446
485
  end repeat
447
486
  end repeat
448
487
  if theTab is missing value then return "ERR"
449
- set newterm to (split (focused terminal of theTab) direction $dir)
488
+ set srcTerm to missing value
489
+ repeat with tm in terminals of theTab
490
+ if (id of tm) is "$target" then set srcTerm to tm
491
+ end repeat
492
+ if srcTerm is missing value then set srcTerm to (focused terminal of theTab)
493
+ set newterm to (split srcTerm direction $dir)
450
494
  delay 0.25
451
495
  input text ("bash $launcher" & return) to newterm
452
496
  return (id of newterm)
@@ -455,6 +499,7 @@ OSA
455
499
  )"
456
500
  fi
457
501
  fi
502
+ terms+=("$termid")
458
503
  record_state "$PROJ" "cmux" "$AGENT" "$termid"
459
504
  echo " → $AGENT seat in cmux workspace ($PROJ)"
460
505
  i=$(( i + 1 ))
package/bin/doctor.mjs CHANGED
@@ -13,14 +13,23 @@ const H = homedir();
13
13
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
14
14
  const has = (c) => { try { execSync(`command -v ${c}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
15
15
  const read = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
16
- const ok = (m) => console.log(` ✓ ${m}`);
17
- const warn = (m, fix) => { console.log(` ✗ ${m}`); if (fix) console.log(` → ${fix}`); issues++; };
16
+ // --json makes the SAME engine feed the desktop app. Without it the app would have to re-implement
17
+ // detection (or parse this text), and the two would drift the CLI would say a seat is wired while
18
+ // the app said otherwise, with no way to tell which was right.
19
+ const JSON_MODE = process.argv.includes("--json");
20
+ const REPORT = { ok: [], issues: [], notes: [], sections: [] };
21
+ let SECTION = "";
22
+ const say = (line) => { if (!JSON_MODE) console.log(line); };
23
+ const section = (name) => { SECTION = name; REPORT.sections.push(name); say((REPORT.sections.length > 1 ? "\n" : "") + name); };
24
+ const ok = (m) => { REPORT.ok.push({ section: SECTION, message: m }); say(` ✓ ${m}`); };
25
+ const warn = (m, fix) => { REPORT.issues.push({ section: SECTION, message: m, fix: fix || null }); say(` ✗ ${m}`); if (fix) say(` → ${fix}`); issues++; };
26
+ const note = (m) => { REPORT.notes.push({ section: SECTION, message: m }); say(` – ${m}`); };
18
27
  let issues = 0;
19
28
 
20
- console.log("TRANTOR DOCTOR\n");
29
+ say("TRANTOR DOCTOR\n");
21
30
 
22
31
  // runtime + hub + client version
23
- console.log("core");
32
+ section("core");
24
33
  Number(process.versions.node.split(".")[0]) >= 18 ? ok(`node ${process.versions.node}`) : warn(`node ${process.versions.node} too old`, "install node >= 18");
25
34
  const cfg = read(join(H, ".agent-bus", "config.json")) || {};
26
35
  const HUB = process.env.RELAY_URL || cfg.url || "http://127.0.0.1:4477";
@@ -41,7 +50,7 @@ if (pkg?.version) {
41
50
  } else warn("could not read trantor version", "reinstall: npm install -g trantor");
42
51
 
43
52
  // claude plugin
44
- console.log("\nclaude (the orchestrator)");
53
+ section("claude (the orchestrator)");
45
54
  if (!has("claude")) warn("claude CLI not found", "install Claude Code: https://claude.com/claude-code");
46
55
  else {
47
56
  const st = read(join(H, ".claude", "settings.json")) || {};
@@ -51,7 +60,7 @@ else {
51
60
  }
52
61
 
53
62
  // crew CLIs: installed / wired / authenticated
54
- console.log("\ncrew CLIs (install any subset — seats follow the work)");
63
+ section("crew CLIs (install any subset — seats follow the work)");
55
64
  const CLIS = [
56
65
  { name: "codex", bin: "codex", wired: () => (readFileSync(join(H, ".codex", "config.toml"), "utf8")).includes("[mcp_servers.relay]"), auth: () => existsSync(join(H, ".codex", "auth.json")), login: "codex (sign in with your ChatGPT account on first run)" },
57
66
  // Gemini CLI was retired 2026-06-18 for free/Pro/Ultra (Google → Antigravity `agy`). Kept as an
@@ -68,7 +77,7 @@ const CLIS = [
68
77
  ];
69
78
  let installed = 0;
70
79
  for (const c of CLIS) {
71
- if (!has(c.bin)) { console.log(` – ${c.name}: not installed (optional)`); continue; }
80
+ if (!has(c.bin)) { note(`${c.name}: not installed (optional)`); continue; }
72
81
  installed++;
73
82
  let wired = false; try { wired = c.wired(); } catch {}
74
83
  wired ? ok(`${c.name}: wired to the bus`) : warn(`${c.name}: installed but not wired`, `node ${join(ROOT, "bin", "connect.mjs")}`);
@@ -78,7 +87,7 @@ for (const c of CLIS) {
78
87
  if (!installed) warn("no crew CLIs found", "install at least one of: codex, gemini, kimi, opencode — Trantor orchestrates whatever you have");
79
88
 
80
89
  // brain
81
- console.log("\nthe brain");
90
+ section("the brain");
82
91
  has("scrooge") || existsSync(join(H, ".local", "bin", "scrooge"))
83
92
  ? ok("economics engine installed (routing + cost ledger active)")
84
93
  : warn("economics engine missing — Advisor runs without live pricing; relay_scrooge dormant", "trantor setup (installs it automatically)");
@@ -87,5 +96,7 @@ prof?.providers && Object.keys(prof.providers).length
87
96
  ? ok(`quota profile set (${Object.entries(prof.providers).map(([k, v]) => `${k}=${v.plan}`).join(", ")})`)
88
97
  : warn("quota profile not set — the Advisor will assume API billing everywhere", `node ${join(ROOT, "bin", "profile.mjs")} set claude=max codex=plus deepseek=api … (use YOUR real plans)`);
89
98
 
90
- console.log(issues ? `\n${issues} issue(s) — fix the → lines above, then re-run the doctor.` : "\nAll clear — open a claude session in any project and say: \"fire up the crew\".");
99
+ say(issues ? `\n${issues} issue(s) — fix the → lines above, then re-run the doctor.` : "\nAll clear — open a claude session in any project and say: \"fire up the crew\".");
100
+ // Must come BEFORE the exit — process.exit() here truncated the report entirely.
101
+ if (JSON_MODE) console.log(JSON.stringify({ ...REPORT, issueCount: issues }));
91
102
  process.exit(issues ? 1 : 0);
Binary file