trantor 0.17.65 → 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.65",
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"
@@ -212,8 +213,16 @@ async function reloadFromStore() {
212
213
  let _overseer = null;
213
214
  import("./lib/overseer.mjs").then(m => { _overseer = m; }).catch(() => {});
214
215
  const OVERSEER_TICK_MS = Number(process.env.RELAY_OVERSEER_TICK_MS || 30 * 1000);
215
- const OVERSEER_DEDUP_MS = Number(process.env.RELAY_OVERSEER_DEDUP_MS || 10 * 60 * 1000);
216
- const overseerWarned = new Map(); // dedup key -> ts
216
+ // How long a condition must be ABSENT before we consider the episode over. This is NOT a re-warn
217
+ // timer: see overseerTick.
218
+ const OVERSEER_CLEAR_MS = Number(process.env.RELAY_OVERSEER_CLEAR_MS || process.env.RELAY_OVERSEER_DEDUP_MS || 10 * 60 * 1000);
219
+ // Standing conditions, keyed by collision identity -> { since, lastTick }. A collision is a STATE,
220
+ // not an event: it persists. Emitting on a 10-minute timer turned the watcher into a metronome —
221
+ // 500 events for 4 distinct conditions (2026-08-12 audit), each one also waking the duty seat for a
222
+ // full turn. Now an episode fires ONCE when it starts and stays quiet while it holds; the entry is
223
+ // forgotten only after the condition has been gone for OVERSEER_CLEAR_MS, so a genuine recurrence
224
+ // warns again.
225
+ const overseerActive = new Map();
217
226
  // Heartbeat for the WATCHER itself: /overseer/status must distinguish "fleet is clear" from "the
218
227
  // overseer stopped ticking" — a monitor that cannot prove it is alive reads as clear when dead.
219
228
  let overseerLastTick = 0;
@@ -240,17 +249,36 @@ function overseerTick() {
240
249
  if (!_overseer?.detectCollisions) return;
241
250
  let collisions = [];
242
251
  try { collisions = _overseer.detectCollisions(overseerInputs()) || []; } catch { return; }
243
- overseerLastTick = now(); overseerLastCollisions = collisions;
244
- const cut = now() - OVERSEER_DEDUP_MS;
245
- for (const [k, ts] of overseerWarned) if (ts < cut) overseerWarned.delete(k);
252
+ const t = now();
253
+ overseerLastTick = t;
246
254
  const pol = overseerPolicy();
255
+ const seen = new Set();
247
256
  for (const c of collisions) {
248
257
  const key = `${c.project} ${c.kind} ${(c.sessions || []).join(",")} ${(c.files || []).join(",")}`;
249
- if (overseerWarned.has(key)) continue;
250
- overseerWarned.set(key, now());
258
+ c.key = key;
259
+ seen.add(key);
260
+ const standing = overseerActive.get(key);
261
+ if (standing) { standing.lastTick = t; c.since = standing.since; continue; } // holds — stay quiet
262
+ overseerActive.set(key, { since: t, lastTick: t });
263
+ c.since = t;
251
264
  appendEvent("overseer.warn", c.project, "overseer",
252
265
  { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
253
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
+ }
254
282
  const level = _overseer.levelFor ? _overseer.levelFor(c.project, pol.autonomy) : 1;
255
283
  if (level >= 3 && c.kind === "file-conflict") {
256
284
  const g = { id: ++state.verifyGateSeq, project: c.project, status: "open", ts: now(),
@@ -260,8 +288,18 @@ function overseerTick() {
260
288
  appendEvent("verify.gate.opened", c.project, "overseer", { gateId: g.id, claim: g.claim, why: g.why });
261
289
  }
262
290
  }
291
+ // Episode end: a condition gone for the whole clear window is over, so a LATER recurrence is a
292
+ // new episode and warns again. Without this the map would grow forever and nothing could re-fire.
293
+ for (const [k, v] of overseerActive) {
294
+ if (!seen.has(k) && t - v.lastTick > OVERSEER_CLEAR_MS) overseerActive.delete(k);
295
+ }
296
+ overseerLastCollisions = collisions;
263
297
  }
264
298
  setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
299
+ // setInterval waits a FULL period before its first call, so for 30s after every restart the watcher
300
+ // had no lastTick and honestly reported itself stalled. Tick once shortly after boot (the delay lets
301
+ // the lazy lib import land) so a restarted hub proves it is alive immediately.
302
+ setTimeout(overseerTick, 2000).unref?.();
265
303
 
266
304
  // --- the DUTY AGENT feed (deterministic escalation; the seat itself is bin/duty.mjs) -----------
267
305
  // RELAY_DUTY_SESSION names the always-on triage seat (e.g. "claude:fleet"). The hub DMs it when:
@@ -270,7 +308,11 @@ setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
270
308
  // 2. the overseer emits a warning (wired inside overseerTick below).
271
309
  // Escalations are hub-authored ("hub:duty") — they never impersonate a session — and dedup per
272
310
  // message id so a standing outage escalates once, not every tick.
273
- 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 || "");
274
316
  const DUTY_UNDELIVERED_MS = Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 10 * 60 * 1000);
275
317
  const dutyEscalated = new Set();
276
318
  function hubSend(to, text, project) {
@@ -613,6 +655,31 @@ function filterReadable(auth, rows, projectOf) {
613
655
  if (AUTH_MODE !== "enforce" && !auth?.identity) return rows;
614
656
  return rows.filter(row => canRead(auth, projectOf(row)));
615
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
+ }
616
683
  function inboxReadable(auth, msg, session) {
617
684
  if (msg.to === session) return !auth?.identity || String(auth.identity.name || "") === String(session || "");
618
685
  return canRead(auth, msg.project || "");
@@ -1030,7 +1097,7 @@ const server = http.createServer(async (req, res) => {
1030
1097
  engine: !!_overseer?.detectCollisions,
1031
1098
  lastTickTs: overseerLastTick,
1032
1099
  tickMs: OVERSEER_TICK_MS,
1033
- dedupMs: OVERSEER_DEDUP_MS,
1100
+ clearMs: OVERSEER_CLEAR_MS,
1034
1101
  dutySession: DUTY_SESSION || "",
1035
1102
  watching: {
1036
1103
  sessions: livePeers.length,
@@ -1040,8 +1107,10 @@ const server = http.createServer(async (req, res) => {
1040
1107
  },
1041
1108
  autonomy: pol.autonomy,
1042
1109
  links: pol.links,
1043
- warnings: overseerLastCollisions,
1044
- warnedRecent: overseerWarned.size,
1110
+ // `since` turns a detection into a duration — "standing 4h" reads very differently from
1111
+ // "just started", and that distinction is the whole point of episode-based warning.
1112
+ warnings: overseerLastCollisions.map(c => ({ ...c, since: c.since || 0 })),
1113
+ standing: overseerActive.size,
1045
1114
  });
1046
1115
  }
1047
1116
  if (req.method === "GET" && P === "/overseer/context") {
@@ -1087,6 +1156,15 @@ const server = http.createServer(async (req, res) => {
1087
1156
  if (flipped) dirty = true;
1088
1157
  return json(res, 200, { ok: true, superseded: flipped });
1089
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
+ }
1090
1168
  if (req.method === "POST" && P === "/overseer/narrate") {
1091
1169
  const b = await body(req);
1092
1170
  const ev = state.events.find(e => e.id === Number(b.eventId) && e.type === "overseer.warn");
@@ -1126,7 +1204,7 @@ const server = http.createServer(async (req, res) => {
1126
1204
  if (req.method === "GET" && P === "/peers") {
1127
1205
  prunePeers();
1128
1206
  const cutoff = now() - ONLINE_MS;
1129
- const peerRows = filterReadable(auth, Object.entries(state.peers), ([, v]) => v.project || "");
1207
+ const peerRows = filterDiscoverable(auth, Object.entries(state.peers), ([, v]) => v.project || "");
1130
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 || "",
1131
1209
  pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "",
1132
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/lib/overseer.mjs CHANGED
@@ -1,5 +1,12 @@
1
- const PEER_LIVE_MS = 5 * 60 * 1000;
2
- const CLAIM_LIVE_MS = 10 * 60 * 1000;
1
+ // Windows are read from env ONCE at load (calls stay pure): without an override, a test cannot
2
+ // observe a condition CLEARING — peers stay "live" for five minutes no matter what the test does,
3
+ // so the episode-recurrence path was untestable and therefore unproven.
4
+ const PEER_LIVE_MS = Number(process.env.RELAY_OVERSEER_PEER_LIVE_MS || 5 * 60 * 1000);
5
+ const CLAIM_LIVE_MS = Number(process.env.RELAY_OVERSEER_CLAIM_LIVE_MS || 10 * 60 * 1000);
6
+ // "Actively executing", not merely "window open" — the same 90s bar the desktop app calls `busy`
7
+ // (the heartbeat fires on tool calls, so a fresher-than-90s peer is mid-turn). Presence alone is
8
+ // too weak a signal to call a collision: see the linked-activity note below.
9
+ const WORK_LIVE_MS = Number(process.env.RELAY_OVERSEER_WORK_LIVE_MS || 90 * 1000);
3
10
 
4
11
  const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity"]);
5
12
 
@@ -119,18 +126,40 @@ export function detectCollisions({ peers = [], claims = [], links = [], autonomy
119
126
  });
120
127
  }
121
128
 
129
+ // A link is DECLARED — the operator already told us these projects move together. Warning merely
130
+ // because both have a session OPEN restates that declaration, and it stays true for hours:
131
+ // crebral-health ↔ crebral-scribe produced 468 identical warnings across 8 days (2026-08-12
132
+ // audit), which is how a monitor teaches you to ignore it. The signal worth raising is
133
+ // CONCURRENT WORK — each side actually executing (heartbeat inside WORK_LIVE_MS) or holding a
134
+ // fresh file claim. Two idle-open windows are not a collision.
135
+ const workingByProject = new Map();
136
+ for (const peer of livePeers) {
137
+ if (!isFresh(peer?.lastSeen, at, WORK_LIVE_MS)) continue;
138
+ const sessions = workingByProject.get(peer.project) ?? [];
139
+ sessions.push(peer.session);
140
+ workingByProject.set(peer.project, sessions);
141
+ }
142
+ for (const claim of asArray(claims)) {
143
+ const project = clean(claim?.project);
144
+ const session = clean(claim?.session);
145
+ if (!project || !session || !isFresh(claim?.ts, at, CLAIM_LIVE_MS)) continue;
146
+ const sessions = workingByProject.get(project) ?? [];
147
+ sessions.push(session);
148
+ workingByProject.set(project, sessions);
149
+ }
150
+
122
151
  for (const link of asArray(links)) {
123
152
  const projects = sortedStrings(link?.projects ?? []);
124
153
  if (projects.length < 2) continue;
125
- const activeProjects = projects.filter((project) => (sessionsByProject.get(project) ?? []).length > 0);
154
+ const activeProjects = projects.filter((project) => (workingByProject.get(project) ?? []).length > 0);
126
155
  if (activeProjects.length < 2) continue;
127
- const sessions = sortedStrings(activeProjects.flatMap((project) => sessionsByProject.get(project) ?? []));
156
+ const sessions = sortedStrings(activeProjects.flatMap((project) => workingByProject.get(project) ?? []));
128
157
  pushCollision(out, {
129
158
  project: activeProjects[0],
130
159
  kind: "linked-activity",
131
160
  sessions,
132
161
  files: [],
133
- detail: `Linked projects ${activeProjects.join(", ")} have live sessions ${sessions.join(", ")}.`,
162
+ detail: `Linked projects ${activeProjects.join(", ")} are being worked on at the same time by ${sessions.join(", ")}.`,
134
163
  });
135
164
  }
136
165
 
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.65",
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": [