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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -0
- package/bin/adopt.mjs +117 -0
- package/bin/cli.mjs +94 -1
- package/bin/crew-runner.mjs +45 -8
- package/bin/crew.sh +59 -14
- package/bin/doctor.mjs +20 -9
- package/bin/migrate-store.mjs +0 -0
- package/bin/summarize.mjs +100 -0
- package/deploy/backup.sh +49 -0
- package/deploy/docker-compose.yml +29 -0
- package/deploy/hub.env +29 -0
- package/deploy/restore.sh +69 -0
- package/deploy/retention.sh +12 -0
- package/deploy/setup-netcup.sh +87 -0
- package/deploy/trantor-hub.service +50 -0
- package/hooks/agent-notify.mjs +3 -14
- package/hooks/file-claim.mjs +91 -0
- package/hooks/heartbeat.mjs +36 -16
- package/hooks/hooks.json +111 -11
- package/hooks/inbox-deliver.mjs +7 -18
- package/hooks/lib/api.mjs +141 -0
- package/hooks/lib/balance-check.mjs +3 -10
- package/hooks/lib/handoff.mjs +7 -8
- package/hooks/prompt-focus.mjs +8 -12
- package/hooks/sessionstart.mjs +10 -13
- package/hooks/stop-inbox.mjs +106 -0
- package/hooks/subagent-cost.mjs +8 -16
- package/hooks/subagent-start.mjs +3 -15
- package/hooks/todo-sync.mjs +3 -19
- package/hub.mjs +0 -0
- package/lib/enroll.mjs +65 -0
- package/lib/identity.mjs +153 -0
- package/lib/project.mjs +55 -0
- package/lib/scrub.mjs +67 -0
- package/lib/signed-fetch.mjs +40 -0
- package/lib/store-contract.mjs +190 -0
- package/lib/store-pg.mjs +682 -0
- package/mcp.mjs +40 -15
- package/package.json +4 -3
- package/ui.html +150 -4
|
@@ -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.
|
|
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.
|
|
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.
|
|
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":
|
|
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):
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
372
|
-
|
|
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 [ "$
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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 [ "$
|
|
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
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
29
|
+
say("TRANTOR DOCTOR\n");
|
|
21
30
|
|
|
22
31
|
// runtime + hub + client version
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|