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.
@@ -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.54"
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.60",
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.66",
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": {
@@ -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
- if (/quota|insufficient|credit|balance|payment required|402|429|too many requests|rate.?limit|exceeded your|out of (credit|quota)/.test(t)) return "exhausted";
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
- const inner = cli.sid ? `${cmd} | tee /dev/stderr` : cmd;
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
- { 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)" },
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
- console.log(` hub feeds it: undelivered DMs (>${Math.round(Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 600000) / 60000)}m) + overseer warnings — set RELAY_DUTY_SESSION=${SESSION} on the hub service.`);
92
- process.exit(0);
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
- const DUTY_SESSION = String(process.env.RELAY_DUTY_SESSION || "");
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 = filterReadable(auth, Object.entries(state.peers), ([, v]) => v.project || "");
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
- import { z } from "zod";
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", "List other Claude sessions connected to the relay (online in last 5 min).", {}, async () => {
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 Claude session (or 'all' to broadcast).",
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.66",
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": [