trantor 0.17.64 → 0.17.66

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.64",
3
+ "version": "0.17.66",
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,6 +147,22 @@ async function main(stdinRaw) {
147
147
  }
148
148
  } catch {}
149
149
 
150
+ // Overseer narration, same ambient pattern (10-min machine-wide stamp): the narrate worker was
151
+ // built "to run ambiently from the heartbeat" but was never actually wired in — warns sat
152
+ // mechanical forever unless someone ran it by hand. The worker exits in one cheap signed GET per
153
+ // hub when nothing is unnarrated; Scrooge is only invoked when there IS a warn to explain.
154
+ try {
155
+ const narStamp = join(homedir(), ".agent-bus", "narrate.stamp");
156
+ const last = existsSync(narStamp) ? Number(readFileSync(narStamp, "utf8")) || 0 : 0;
157
+ if (Date.now() - last > 10 * 60 * 1000) {
158
+ writeFileSync(narStamp, String(Date.now()));
159
+ const worker = spawn(process.execPath, [join(HERE, "..", "bin", "overseer-narrate.mjs"), "--quiet"], {
160
+ detached: true, stdio: "ignore",
161
+ });
162
+ worker.unref();
163
+ }
164
+ } catch {}
165
+
150
166
  // Same cadence as the presence ping: check context pressure and hand off early
151
167
  // if we've crossed the warn threshold of a known window.
152
168
  await maybeEarlyWarn(stdinRaw, session);
package/hub.mjs CHANGED
@@ -212,8 +212,20 @@ async function reloadFromStore() {
212
212
  let _overseer = null;
213
213
  import("./lib/overseer.mjs").then(m => { _overseer = m; }).catch(() => {});
214
214
  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
215
+ // How long a condition must be ABSENT before we consider the episode over. This is NOT a re-warn
216
+ // timer: see overseerTick.
217
+ const OVERSEER_CLEAR_MS = Number(process.env.RELAY_OVERSEER_CLEAR_MS || process.env.RELAY_OVERSEER_DEDUP_MS || 10 * 60 * 1000);
218
+ // Standing conditions, keyed by collision identity -> { since, lastTick }. A collision is a STATE,
219
+ // not an event: it persists. Emitting on a 10-minute timer turned the watcher into a metronome —
220
+ // 500 events for 4 distinct conditions (2026-08-12 audit), each one also waking the duty seat for a
221
+ // full turn. Now an episode fires ONCE when it starts and stays quiet while it holds; the entry is
222
+ // forgotten only after the condition has been gone for OVERSEER_CLEAR_MS, so a genuine recurrence
223
+ // warns again.
224
+ const overseerActive = new Map();
225
+ // Heartbeat for the WATCHER itself: /overseer/status must distinguish "fleet is clear" from "the
226
+ // overseer stopped ticking" — a monitor that cannot prove it is alive reads as clear when dead.
227
+ let overseerLastTick = 0;
228
+ let overseerLastCollisions = [];
217
229
  function overseerPolicy() {
218
230
  const p = state.orgPolicy && typeof state.orgPolicy === "object" ? state.orgPolicy : {};
219
231
  return {
@@ -236,13 +248,18 @@ function overseerTick() {
236
248
  if (!_overseer?.detectCollisions) return;
237
249
  let collisions = [];
238
250
  try { collisions = _overseer.detectCollisions(overseerInputs()) || []; } catch { return; }
239
- const cut = now() - OVERSEER_DEDUP_MS;
240
- for (const [k, ts] of overseerWarned) if (ts < cut) overseerWarned.delete(k);
251
+ const t = now();
252
+ overseerLastTick = t;
241
253
  const pol = overseerPolicy();
254
+ const seen = new Set();
242
255
  for (const c of collisions) {
243
256
  const key = `${c.project} ${c.kind} ${(c.sessions || []).join(",")} ${(c.files || []).join(",")}`;
244
- if (overseerWarned.has(key)) continue;
245
- overseerWarned.set(key, now());
257
+ c.key = key;
258
+ seen.add(key);
259
+ const standing = overseerActive.get(key);
260
+ if (standing) { standing.lastTick = t; c.since = standing.since; continue; } // holds — stay quiet
261
+ overseerActive.set(key, { since: t, lastTick: t });
262
+ c.since = t;
246
263
  appendEvent("overseer.warn", c.project, "overseer",
247
264
  { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
248
265
  if (DUTY_SESSION) hubSend(DUTY_SESSION, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
@@ -255,8 +272,18 @@ function overseerTick() {
255
272
  appendEvent("verify.gate.opened", c.project, "overseer", { gateId: g.id, claim: g.claim, why: g.why });
256
273
  }
257
274
  }
275
+ // Episode end: a condition gone for the whole clear window is over, so a LATER recurrence is a
276
+ // new episode and warns again. Without this the map would grow forever and nothing could re-fire.
277
+ for (const [k, v] of overseerActive) {
278
+ if (!seen.has(k) && t - v.lastTick > OVERSEER_CLEAR_MS) overseerActive.delete(k);
279
+ }
280
+ overseerLastCollisions = collisions;
258
281
  }
259
282
  setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
283
+ // setInterval waits a FULL period before its first call, so for 30s after every restart the watcher
284
+ // had no lastTick and honestly reported itself stalled. Tick once shortly after boot (the delay lets
285
+ // the lazy lib import land) so a restarted hub proves it is alive immediately.
286
+ setTimeout(overseerTick, 2000).unref?.();
260
287
 
261
288
  // --- the DUTY AGENT feed (deterministic escalation; the seat itself is bin/duty.mjs) -----------
262
289
  // RELAY_DUTY_SESSION names the always-on triage seat (e.g. "claude:fleet"). The hub DMs it when:
@@ -495,7 +522,7 @@ function cmpSemver(a, b) {
495
522
  const AUTH_HEADERS = ["x-trantor-pubkey", "x-trantor-sig", "x-trantor-ts", "x-trantor-nonce"];
496
523
  const PUBLIC_ENDPOINTS = new Set(["/", "/ui", "/health", "/enroll"]);
497
524
  const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy"]);
498
- const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims", "/overseer/context"]);
525
+ const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims", "/overseer/context", "/overseer/status"]);
499
526
  const roleRank = { read: 1, write: 2, owner: 3 };
500
527
  const hasAuthHeaders = (req) => AUTH_HEADERS.some(h => !!req.headers[h]);
501
528
  const authPath = (u) => `${u.pathname}${u.search || ""}`;
@@ -1013,6 +1040,34 @@ const server = http.createServer(async (req, res) => {
1013
1040
  }
1014
1041
  // What a session arriving on <project> needs to know: its autonomy level, who else is live,
1015
1042
  // which files are in flight, which projects are declared codependent, current collisions.
1043
+ if (req.method === "GET" && P === "/overseer/status") {
1044
+ // The Overseer view's backbone: is the watcher ALIVE, and what is it watching right now.
1045
+ // `warnings` is the LIVE detection result from the last tick (pre-dedup), not the event log —
1046
+ // the log answers "what did it do", this answers "what does it see".
1047
+ const pol = overseerPolicy();
1048
+ const cutoff = now() - ONLINE_MS;
1049
+ const livePeers = Object.entries(state.peers).filter(([, v]) => v.lastSeen > cutoff);
1050
+ pruneClaims();
1051
+ return json(res, 200, {
1052
+ engine: !!_overseer?.detectCollisions,
1053
+ lastTickTs: overseerLastTick,
1054
+ tickMs: OVERSEER_TICK_MS,
1055
+ clearMs: OVERSEER_CLEAR_MS,
1056
+ dutySession: DUTY_SESSION || "",
1057
+ watching: {
1058
+ sessions: livePeers.length,
1059
+ projects: new Set(livePeers.map(([, v]) => v.project).filter(Boolean)).size,
1060
+ claims: fileClaims.size,
1061
+ links: pol.links.length,
1062
+ },
1063
+ autonomy: pol.autonomy,
1064
+ links: pol.links,
1065
+ // `since` turns a detection into a duration — "standing 4h" reads very differently from
1066
+ // "just started", and that distinction is the whole point of episode-based warning.
1067
+ warnings: overseerLastCollisions.map(c => ({ ...c, since: c.since || 0 })),
1068
+ standing: overseerActive.size,
1069
+ });
1070
+ }
1016
1071
  if (req.method === "GET" && P === "/overseer/context") {
1017
1072
  const proj = canon(String(q.project || "").slice(0, 80));
1018
1073
  if (!proj) return json(res, 400, { error: "project required" });
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.64",
3
+ "version": "0.17.66",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"