trantor 0.17.66 → 0.17.67
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/bin/crew-runner.mjs +10 -3
- package/bin/doctor.mjs +16 -1
- package/bin/duty.mjs +31 -2
- package/hub.mjs +57 -3
- package/mcp.mjs +51 -6
- package/package.json +2 -2
|
@@ -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.67"
|
|
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.67",
|
|
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.67",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -147,7 +147,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
|
|
|
147
147
|
|
|
148
148
|
// RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
|
|
149
149
|
// always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
|
|
150
|
-
const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go (doing -> testing -> done; run the tests in 'testing', use 'failed' + a report if they break). When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message.`;
|
|
150
|
+
const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go (doing -> testing -> done; run the tests in 'testing', use 'failed' + a report if they break). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message.`;
|
|
151
151
|
|
|
152
152
|
// ---- failure visibility ----------------------------------------------------
|
|
153
153
|
// A turn's CLI can fail (credits exhausted, auth, crash) and the runner would just
|
|
@@ -161,7 +161,9 @@ const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
|
|
|
161
161
|
function classifyFailure(exit, errText) {
|
|
162
162
|
const t = (errText || "").toLowerCase();
|
|
163
163
|
if (exit === 127) return "missing-cli";
|
|
164
|
-
|
|
164
|
+
// "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
|
|
165
|
+
// your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
|
|
166
|
+
if (/quota|insufficient|credit|balance|payment required|402|429|too many requests|rate.?limit|exceeded your|reached your [^.\n]*limit|usage limit|out of (credit|quota)/.test(t)) return "exhausted";
|
|
165
167
|
if (/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/.test(t)) return "auth";
|
|
166
168
|
return "crashed";
|
|
167
169
|
}
|
|
@@ -210,7 +212,12 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
210
212
|
try { appendFileSync(ERRF, "", { flag: "w" }); } catch {}
|
|
211
213
|
// pipefail: without it the sid-capture `| tee` makes a FAILED turn exit 0 (tee's status),
|
|
212
214
|
// so the failure reporter never fires and a dead seat heartbeats green on the bus.
|
|
213
|
-
|
|
215
|
+
// A CLI's own explanation for quitting often goes to STDOUT, not stderr — Claude's usage-limit
|
|
216
|
+
// notice is the case that bit us: ERRF stayed empty, so a plainly exhausted seat was reported as
|
|
217
|
+
// `crashed` and nobody knew to swap it. sid seats already fold stdout into the ERRF stream via
|
|
218
|
+
// `tee /dev/stderr`; the rest now tee straight into ERRF. A real pipeline (not a process
|
|
219
|
+
// substitution) so bash waits for tee to flush before we read the file back.
|
|
220
|
+
const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | tee -a ${ERRF}`;
|
|
214
221
|
const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(tee -a ${ERRF} >&2)`], {
|
|
215
222
|
cwd: DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
216
223
|
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ },
|
package/bin/doctor.mjs
CHANGED
|
@@ -12,6 +12,13 @@ import { fileURLToPath } from "node:url";
|
|
|
12
12
|
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
|
+
// Claude Code keeps its credentials in the macOS Keychain. Attribute-only lookup (no -w, no -g), so
|
|
16
|
+
// it never reads the secret and never raises an access prompt — a GUI prompt from a health check
|
|
17
|
+
// would be worse than the unknown it answers.
|
|
18
|
+
const keychainHas = (svc) => {
|
|
19
|
+
if (process.platform !== "darwin") return false;
|
|
20
|
+
try { execSync(`security find-generic-password -s ${JSON.stringify(svc)}`, { stdio: "ignore" }); return true; } catch { return false; }
|
|
21
|
+
};
|
|
15
22
|
const read = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
16
23
|
// --json makes the SAME engine feed the desktop app. Without it the app would have to re-implement
|
|
17
24
|
// detection (or parse this text), and the two would drift — the CLI would say a seat is wired while
|
|
@@ -62,7 +69,15 @@ else {
|
|
|
62
69
|
// crew CLIs: installed / wired / authenticated
|
|
63
70
|
section("crew CLIs (install any subset — seats follow the work)");
|
|
64
71
|
const CLIS = [
|
|
65
|
-
|
|
72
|
+
// Claude is a SEAT, not only the orchestrator — crew-runner.mjs has a `claude` entry and the fleet
|
|
73
|
+
// duty agent runs on it. It was checked only under "claude (the orchestrator)", which the Agents
|
|
74
|
+
// view filters out, so the one harness that is always present had no card. Wired = the plugin,
|
|
75
|
+
// since that is what carries the relay MCP server into the session.
|
|
76
|
+
{ name: "claude", bin: "claude",
|
|
77
|
+
wired: () => Object.keys((read(join(H, ".claude", "settings.json")) || {}).enabledPlugins || {}).some(k => k.startsWith("agent-bus@") || k.startsWith("trantor@")),
|
|
78
|
+
auth: () => !!process.env.ANTHROPIC_API_KEY || existsSync(join(H, ".claude", ".credentials.json")) || keychainHas("Claude Code-credentials"),
|
|
79
|
+
login: "claude (sign in with your Anthropic account on first run)" },
|
|
80
|
+
{ 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)" },
|
|
66
81
|
// Gemini CLI was retired 2026-06-18 for free/Pro/Ultra (Google → Antigravity `agy`). Kept as an
|
|
67
82
|
// optional seat for enterprise/paid-key holders; for everyone else the seat moved to GLM/opencode,
|
|
68
83
|
// and Gemini lives on only as a Scrooge cheap-model via GEMINI_API_KEY (the API/models aren't retired).
|
package/bin/duty.mjs
CHANGED
|
@@ -63,6 +63,22 @@ async function ensureFleetIdentity(hub) {
|
|
|
63
63
|
return true;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Tell the hub which seat is on duty. Printing "set RELAY_DUTY_SESSION=… on the hub service" was
|
|
67
|
+
// advice nobody could act on: the fleet hub is usually REMOTE, so no local env var reaches it, and
|
|
68
|
+
// the hub read that var once at boot anyway. The seat knows it came up, so the seat says so.
|
|
69
|
+
async function registerDutySeat(hub, session) {
|
|
70
|
+
const r = await sfetchJson(`${hub}/overseer/duty`, {
|
|
71
|
+
identity: loadOrCreate(SESSION, "agent"), payload: { session }, signal: AbortSignal.timeout(5000),
|
|
72
|
+
}).catch((e) => ({ ok: false, status: 0, _err: e?.message || String(e) }));
|
|
73
|
+
if (r?.ok) return true;
|
|
74
|
+
const why = r?.status === 404
|
|
75
|
+
? `that hub predates /overseer/duty — redeploy it, or set RELAY_DUTY_SESSION=${session} on the hub service`
|
|
76
|
+
: (r?._err || `HTTP ${r?.status}`);
|
|
77
|
+
console.error(` ⚠️ hub did NOT register the duty seat: ${why}`);
|
|
78
|
+
console.error(" the seat is running, but the hub will not feed it undelivered DMs or overseer warnings.");
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
function alivePid() {
|
|
67
83
|
try {
|
|
68
84
|
const pid = Number(readFileSync(PIDF, "utf8"));
|
|
@@ -88,8 +104,9 @@ if (cmd === "up") {
|
|
|
88
104
|
child.unref();
|
|
89
105
|
writeFileSync(PIDF, String(child.pid));
|
|
90
106
|
console.log(`— duty agent up: ${SESSION} (pid ${child.pid}) watching ${hub} — log: ${LOGF}`);
|
|
91
|
-
|
|
92
|
-
process.
|
|
107
|
+
const fed = await registerDutySeat(hub, SESSION);
|
|
108
|
+
if (fed) console.log(` hub feeds it: undelivered DMs (>${Math.round(Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 600000) / 60000)}m) + overseer warnings.`);
|
|
109
|
+
process.exit(0); // the seat IS up; a hub that won't feed it is a warning, not a failed start
|
|
93
110
|
}
|
|
94
111
|
|
|
95
112
|
if (cmd === "down") {
|
|
@@ -98,6 +115,9 @@ if (cmd === "down") {
|
|
|
98
115
|
else console.log("no duty seat running");
|
|
99
116
|
try { execSync(`pkill -f "crew-runner.mjs ${AGENT} ${DIR}"`, { stdio: "ignore" }); } catch {}
|
|
100
117
|
try { rmSync(PIDF, { force: true }); } catch {}
|
|
118
|
+
// Clear the hub's pointer too — escalations aimed at a seat that no longer exists are messages
|
|
119
|
+
// sent into a hole, and the hub has no other way to learn the seat went away.
|
|
120
|
+
await registerDutySeat(fleetHub(), "");
|
|
101
121
|
process.exit(0);
|
|
102
122
|
}
|
|
103
123
|
|
|
@@ -105,6 +125,15 @@ if (cmd === "down") {
|
|
|
105
125
|
{
|
|
106
126
|
const pid = alivePid();
|
|
107
127
|
console.log(pid ? `duty seat RUNNING (pid ${pid}) as ${SESSION}` : "duty seat NOT running");
|
|
128
|
+
// A running seat the hub isn't feeding looks identical to a working one from the outside — which
|
|
129
|
+
// is the whole failure mode this command exists to make visible. So ask the hub, don't assume.
|
|
130
|
+
const hub = fleetHub();
|
|
131
|
+
const ov = await sfetchJson(`${hub}/overseer/status`, { method: "GET", identity: loadOrCreate(SESSION, "agent"), signal: AbortSignal.timeout(5000) })
|
|
132
|
+
.then((r) => (r?.ok ? r.json() : null)).catch(() => null);
|
|
133
|
+
if (!ov) console.log(`hub feed: UNKNOWN — could not read ${hub}/overseer/status`);
|
|
134
|
+
else if (!ov.dutySession) console.log(`hub feed: NOT WIRED — ${hub} has no duty seat registered; run \`trantor duty up\``);
|
|
135
|
+
else if (ov.dutySession !== SESSION) console.log(`hub feed: pointed at ${ov.dutySession}, NOT ${SESSION} — another seat owns duty on ${hub}`);
|
|
136
|
+
else console.log(`hub feed: wired — ${hub} escalates to ${SESSION}`);
|
|
108
137
|
try {
|
|
109
138
|
const lines = readFileSync(join(BUS, "logs", `${AGENT}-fleet.jsonl`), "utf8").trim().split("\n").slice(-3);
|
|
110
139
|
console.log("last turns:"); for (const l of lines) console.log(` ${l}`);
|
package/hub.mjs
CHANGED
|
@@ -86,7 +86,7 @@ function scanTelemetry() {
|
|
|
86
86
|
// TIMELINE view are untouched; every NEW type is dotted ("message", "presence.online", …) and is
|
|
87
87
|
// filtered OUT of /history. Loads from the old `cardEvents` key when `events` is absent.
|
|
88
88
|
function emptyState() {
|
|
89
|
-
return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {} };
|
|
89
|
+
return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {}, dutySession: "" };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
function normalizeState(loaded = {}) {
|
|
@@ -111,6 +111,7 @@ function normalizeState(loaded = {}) {
|
|
|
111
111
|
s.instances = loaded.instances && typeof loaded.instances === "object" ? loaded.instances : {};
|
|
112
112
|
s.focus = loaded.focus && typeof loaded.focus === "object" ? loaded.focus : {};
|
|
113
113
|
s.orgPolicy = loaded.orgPolicy && typeof loaded.orgPolicy === "object" ? loaded.orgPolicy : {};
|
|
114
|
+
s.dutySession = String(loaded.dutySession || "");
|
|
114
115
|
for (const [session, v] of Object.entries(loaded.peers || {})) {
|
|
115
116
|
// migrate old numeric form
|
|
116
117
|
s.peers[session] = typeof v === "number"
|
|
@@ -263,6 +264,21 @@ function overseerTick() {
|
|
|
263
264
|
appendEvent("overseer.warn", c.project, "overseer",
|
|
264
265
|
{ kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
|
|
265
266
|
if (DUTY_SESSION) hubSend(DUTY_SESSION, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
|
|
267
|
+
// INTRODUCE the parties to each other. Telling two sessions to "coordinate over the bus" is
|
|
268
|
+
// useless if neither knows the other's session id, and until now the warning went only to the
|
|
269
|
+
// duty seat and the log — so coordination needed a human to carry the ids across. Hand each
|
|
270
|
+
// party the others' ids at the moment coordination is warranted. This sits inside the
|
|
271
|
+
// episode-start branch, so it fires ONCE per episode, not once per tick: a standing condition
|
|
272
|
+
// must not re-wake two sessions every 30 seconds.
|
|
273
|
+
const parties = [...new Set(c.sessions || [])].filter(s => s && s !== DUTY_SESSION);
|
|
274
|
+
if (parties.length > 1) {
|
|
275
|
+
for (const me of parties) {
|
|
276
|
+
const others = parties.filter(p => p !== me);
|
|
277
|
+
hubSend(me,
|
|
278
|
+
`🤝 OVERSEER ${c.kind}: you and ${others.join(", ")} are working on overlapping ground${c.files?.length ? ` (${c.files.slice(0, 3).join(", ")})` : ""}. ${c.detail || ""} Coordinate directly — relay_send to ${others[0]} — and split the work between you. No human needs to relay this.`,
|
|
279
|
+
c.project);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
266
282
|
const level = _overseer.levelFor ? _overseer.levelFor(c.project, pol.autonomy) : 1;
|
|
267
283
|
if (level >= 3 && c.kind === "file-conflict") {
|
|
268
284
|
const g = { id: ++state.verifyGateSeq, project: c.project, status: "open", ts: now(),
|
|
@@ -292,7 +308,11 @@ setTimeout(overseerTick, 2000).unref?.();
|
|
|
292
308
|
// 2. the overseer emits a warning (wired inside overseerTick below).
|
|
293
309
|
// Escalations are hub-authored ("hub:duty") — they never impersonate a session — and dedup per
|
|
294
310
|
// message id so a standing outage escalates once, not every tick.
|
|
295
|
-
|
|
311
|
+
// Settable at runtime via POST /overseer/duty, because the seat is the only party that knows it
|
|
312
|
+
// came up — and it often enrolls with a REMOTE hub, where no local env var could ever reach.
|
|
313
|
+
// Env still wins at boot (an operator's declared config beats a seat's claim); otherwise the last
|
|
314
|
+
// registered seat is restored from state, so a hub restart doesn't silently end the duty feed.
|
|
315
|
+
let DUTY_SESSION = String(process.env.RELAY_DUTY_SESSION || state.dutySession || "");
|
|
296
316
|
const DUTY_UNDELIVERED_MS = Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 10 * 60 * 1000);
|
|
297
317
|
const dutyEscalated = new Set();
|
|
298
318
|
function hubSend(to, text, project) {
|
|
@@ -635,6 +655,31 @@ function filterReadable(auth, rows, projectOf) {
|
|
|
635
655
|
if (AUTH_MODE !== "enforce" && !auth?.identity) return rows;
|
|
636
656
|
return rows.filter(row => canRead(auth, projectOf(row)));
|
|
637
657
|
}
|
|
658
|
+
// DISCOVERY follows declared links, and is deliberately wider than read.
|
|
659
|
+
//
|
|
660
|
+
// Sending across projects was never blocked: /send authorizes against the SENDER's project, so any
|
|
661
|
+
// session can DM any session id it happens to know. Only the ROSTER was scoped — which meant two
|
|
662
|
+
// sessions the operator had explicitly declared codependent could not learn each other's ids. The
|
|
663
|
+
// overseer would tell both of them to "coordinate over the bus" and neither could find the other,
|
|
664
|
+
// so the only remaining channel was the human. That is the exact traffic-cop role this project
|
|
665
|
+
// exists to delete.
|
|
666
|
+
//
|
|
667
|
+
// A link is an operator declaration that two projects share resources. Treating it as mutual
|
|
668
|
+
// discovery grants nothing a linked pair wasn't already told to do.
|
|
669
|
+
function canDiscover(auth, project) {
|
|
670
|
+
if (canRead(auth, project)) return true;
|
|
671
|
+
const proj = canon(project || "");
|
|
672
|
+
if (!proj) return false;
|
|
673
|
+
for (const l of overseerPolicy().links) {
|
|
674
|
+
const ps = (l.projects || []).map(p => canon(p));
|
|
675
|
+
if (ps.includes(proj) && ps.some(p => p !== proj && canRead(auth, p))) return true;
|
|
676
|
+
}
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
function filterDiscoverable(auth, rows, projectOf) {
|
|
680
|
+
if (AUTH_MODE !== "enforce" && !auth?.identity) return rows;
|
|
681
|
+
return rows.filter(row => canDiscover(auth, projectOf(row)));
|
|
682
|
+
}
|
|
638
683
|
function inboxReadable(auth, msg, session) {
|
|
639
684
|
if (msg.to === session) return !auth?.identity || String(auth.identity.name || "") === String(session || "");
|
|
640
685
|
return canRead(auth, msg.project || "");
|
|
@@ -1111,6 +1156,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1111
1156
|
if (flipped) dirty = true;
|
|
1112
1157
|
return json(res, 200, { ok: true, superseded: flipped });
|
|
1113
1158
|
}
|
|
1159
|
+
if (req.method === "POST" && P === "/overseer/duty") {
|
|
1160
|
+
const b = await body(req);
|
|
1161
|
+
if (b.session === undefined) return json(res, 400, { error: "session required (send \"\" to clear the duty seat)" });
|
|
1162
|
+
const session = String(b.session).slice(0, 120);
|
|
1163
|
+
DUTY_SESSION = session;
|
|
1164
|
+
state.dutySession = session;
|
|
1165
|
+
dirty = true;
|
|
1166
|
+
return json(res, 200, { ok: true, dutySession: DUTY_SESSION });
|
|
1167
|
+
}
|
|
1114
1168
|
if (req.method === "POST" && P === "/overseer/narrate") {
|
|
1115
1169
|
const b = await body(req);
|
|
1116
1170
|
const ev = state.events.find(e => e.id === Number(b.eventId) && e.type === "overseer.warn");
|
|
@@ -1150,7 +1204,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1150
1204
|
if (req.method === "GET" && P === "/peers") {
|
|
1151
1205
|
prunePeers();
|
|
1152
1206
|
const cutoff = now() - ONLINE_MS;
|
|
1153
|
-
const peerRows =
|
|
1207
|
+
const peerRows = filterDiscoverable(auth, Object.entries(state.peers), ([, v]) => v.project || "");
|
|
1154
1208
|
return json(res, 200, { hubVersion: HUB_VERSION, authMode: AUTH_MODE, peers: peerRows.map(([s, v]) => ({ session: s, lastSeen: v.lastSeen, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status), project: v.project || "",
|
|
1155
1209
|
pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "",
|
|
1156
1210
|
llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) })) });
|
package/mcp.mjs
CHANGED
|
@@ -3,17 +3,62 @@
|
|
|
3
3
|
// tools to talk to OTHER live agent sessions through the relay hub. Loaded per-session
|
|
4
4
|
// via the agent's MCP config. Identity + hub URL come from env (RELAY_SESSION, RELAY_URL).
|
|
5
5
|
// Loading this server AUTO-REGISTERS the session — so presence works on every agent.
|
|
6
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
6
|
import { writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
9
|
-
import { join, basename } from "node:path";
|
|
7
|
+
import { join, basename, dirname } from "node:path";
|
|
10
8
|
import { homedir, hostname } from "node:os";
|
|
11
9
|
import { execSync, spawnSync } from "node:child_process";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
import { advise } from "./bin/advise.mjs";
|
|
13
13
|
import { resolveProject, hostId, resolveHub } from "./lib/project.mjs";
|
|
14
14
|
import { signedPost, signedGet } from "./hooks/lib/api.mjs";
|
|
15
15
|
import { assertNoSecrets } from "./lib/scrub.mjs";
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
// ---- runtime dep resolution -------------------------------------------------
|
|
18
|
+
// `claude plugin install` snapshots the REPO, not an npm tarball, so a GitHub-sourced
|
|
19
|
+
// plugin ships no node_modules — and a static `import "@modelcontextprotocol/sdk/..."`
|
|
20
|
+
// then dies with ERR_MODULE_NOT_FOUND before a single line runs. The failure is silent
|
|
21
|
+
// from the user's side: every relay tool just disappears. So resolve these two ourselves.
|
|
22
|
+
// Normal path is untouched (plain `import(spec)`, ESM build, deps present); only when that
|
|
23
|
+
// comes back NOT_FOUND do we borrow the tree from the globally installed `trantor`, which
|
|
24
|
+
// npm always gives real dependencies at the same version as the plugin.
|
|
25
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const req = createRequire(import.meta.url);
|
|
27
|
+
|
|
28
|
+
let fallbackRoots = null;
|
|
29
|
+
function borrowRoots() {
|
|
30
|
+
if (fallbackRoots) return fallbackRoots;
|
|
31
|
+
const roots = [];
|
|
32
|
+
const add = (p) => { if (p && !roots.includes(p)) roots.push(p); };
|
|
33
|
+
// Cheap guesses first — every one of these is a string join, no process spawn.
|
|
34
|
+
if (process.env.npm_config_prefix) add(join(process.env.npm_config_prefix, "lib", "node_modules"));
|
|
35
|
+
add(join(dirname(process.execPath), "..", "lib", "node_modules")); // homebrew, nvm, volta, asdf
|
|
36
|
+
// Only shell out if the guesses missed — `npm root -g` costs ~0.5s of MCP startup.
|
|
37
|
+
if (!roots.some((r) => existsSync(join(r, "trantor")))) {
|
|
38
|
+
try { add(execSync("npm root -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim()); } catch {}
|
|
39
|
+
}
|
|
40
|
+
fallbackRoots = roots.flatMap((r) => [join(r, "trantor"), r]);
|
|
41
|
+
return fallbackRoots;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function dep(spec) {
|
|
45
|
+
try {
|
|
46
|
+
return await import(spec);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err?.code !== "ERR_MODULE_NOT_FOUND" && err?.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw err;
|
|
49
|
+
}
|
|
50
|
+
for (const path of [HERE, ...borrowRoots()]) {
|
|
51
|
+
try { return await import(pathToFileURL(req.resolve(spec, { paths: [path] })).href); } catch {}
|
|
52
|
+
}
|
|
53
|
+
throw new Error(
|
|
54
|
+
`[trantor-mcp] cannot resolve '${spec}'. This plugin snapshot has no node_modules and no global ` +
|
|
55
|
+
`trantor install was found to borrow from. Fix: npm i -g trantor (or: cd ${HERE} && npm install --omit=dev)`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { McpServer } = await dep("@modelcontextprotocol/sdk/server/mcp.js");
|
|
60
|
+
const { StdioServerTransport } = await dep("@modelcontextprotocol/sdk/server/stdio.js");
|
|
61
|
+
const { z } = await dep("zod");
|
|
17
62
|
|
|
18
63
|
// Stable project key: RELAY_PROJECT > git-repo-root basename > cwd basename. Keying by
|
|
19
64
|
// the git root (not a loose cwd basename) stops one repo fragmenting into several lanes.
|
|
@@ -166,7 +211,7 @@ server.tool("relay_board", "Show a project's Kanban board (all cards + their sta
|
|
|
166
211
|
return { content: [{ type: "text", text: `${proj} board\n${cols.join("\n")}` }] };
|
|
167
212
|
});
|
|
168
213
|
|
|
169
|
-
server.tool("relay_peers", "
|
|
214
|
+
server.tool("relay_peers", "Find who you can talk to: the live agent sessions on the relay (online in last 5 min), including sessions in projects linked to yours. Call this BEFORE concluding you have no way to reach someone — the session ids it returns are what relay_send takes.", {}, async () => {
|
|
170
215
|
const { peers } = await api("GET", "/peers");
|
|
171
216
|
const lines = peers.map(p => {
|
|
172
217
|
// health surfaces a failing-but-alive agent (runner-reported) — not a green lie
|
|
@@ -177,7 +222,7 @@ server.tool("relay_peers", "List other Claude sessions connected to the relay (o
|
|
|
177
222
|
return { content: [{ type: "text", text: lines.join("\n") || "no peers yet" }] };
|
|
178
223
|
});
|
|
179
224
|
|
|
180
|
-
server.tool("relay_send", "Send a live message to another
|
|
225
|
+
server.tool("relay_send", "Send a live message to another agent session (or 'all' to broadcast). Reach the other agent YOURSELF: if you are about to ask the human to pass something along, tell the session directly instead — asking a person to carry a message between two agents is a failure, not politeness. Don't know the id? relay_peers lists them, linked projects included. Cross-project sends are allowed.",
|
|
181
226
|
{ to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body") },
|
|
182
227
|
async ({ to, text }) => {
|
|
183
228
|
// The event log is append-only — a secret in it is unrecoverable, so refuse BEFORE
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.67",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"pg": "^8.22.0"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && bash test-crew.sh"
|
|
14
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && bash test-crew.sh"
|
|
15
15
|
},
|
|
16
16
|
"description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
17
17
|
"files": [
|