trantor 0.18.32 → 0.18.34

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.
@@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url";
18
18
  import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
19
19
  import { signedPost } from "./api.mjs";
20
20
  import { loadAutonomy, resolveAutonomy } from "../../lib/autonomy.mjs";
21
+ import { resolveProject, orchSessionsPath } from "../../lib/project.mjs";
21
22
 
22
23
  // Writer and reader MUST resolve the same directory — see lib/project.mjs busDir(). This used to
23
24
  // honour only RELAY_DATA_DIR while the reader honoured neither override.
@@ -389,8 +390,10 @@ export function appendHandoffState(id, state, by = "") {
389
390
  } catch { return false; }
390
391
  }
391
392
 
392
- export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary, force = false }) {
393
- const projectName = basename(projectDir);
393
+ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary, force = false, projectName: projectNameArg }) {
394
+ // #6074: the NAME may come from the session's registration (resolveHandoffSurface), not from
395
+ // this directory's basename — a subfolder cwd must not rename the project on the record.
396
+ const projectName = projectNameArg || basename(projectDir);
394
397
  // Server-side storm guard: a session running OLD hooks (before the local markHandedOff guard) re-fires
395
398
  // context-warn handoffs every few minutes — the crebral-cortex storm (9 in 49 min, each spawning a
396
399
  // window). Ask the hub for clearance (rate-limit per project+session); a non-forced handoff inside the
@@ -541,6 +544,12 @@ export function maybeSpawn(projectDir, conf = readConfig()) {
541
544
  try {
542
545
  if (process.platform !== "darwin") return false;
543
546
  if (process.env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
547
+ // #6074: a session in a hosted pane never gets a Terminal window — the pane is the successor
548
+ // surface, and the pane claims the handoff (trantor open) on its own.
549
+ if (paneSurfaceEnv()) {
550
+ process.stderr.write(`[trantor] session lives in herdr pane ${paneSurfaceEnv()} — no Terminal window; the pane claims the handoff\n`);
551
+ return false;
552
+ }
544
553
  if (conf.autoHandoffPrompt === false) return false;
545
554
  if (hasOrchPane(basename(projectDir))) {
546
555
  process.stderr.write(`[trantor] orch pane hosts ${basename(projectDir)} — no Terminal window; the pane claims the handoff on its next open\n`);
@@ -585,9 +594,52 @@ export function hasOrchPane(projectName) {
585
594
  } catch { return false; }
586
595
  }
587
596
 
597
+ // ---- #6074: WHERE the session lives, and WHICH project it is ----------------
598
+ // Witnessed 2026-09-02 (crebral-scribe/ios): a pane session ran the handoff skill with its shell
599
+ // cwd in a subfolder. The project name came from basename(cwd) = "ios", the pane lookup found no
600
+ // "ios" row, and the baton fell to the window leg — whose front-Terminal-window fallback picked a
601
+ // window the pane session never owned, spawned a stray Terminal session, and armed a close on a
602
+ // stranger's window. Two facts the code ignored: a session's OWN env knows where it lives
603
+ // (HERDR_PANE_ID — herdr sets it in every pane), and the project's REGISTRATION knows its name
604
+ // (the `trantor open` badge, RELAY_PROJECT, the orch-sessions map) long before cwd is worth
605
+ // consulting. One resolver, shared by write-handoff.mjs --baton AND bin/baton.mjs, so the skill
606
+ // path and the CLI path cannot diverge.
607
+ export function paneSurfaceEnv(env = process.env) {
608
+ return String(env.HERDR_PANE_ID || "").trim();
609
+ }
610
+
611
+ // Reverse orch-sessions.txt lookup: which project recorded THIS session id as its orchestrator
612
+ // thread. One row per project, "<project>\t<sid>" — written by `trantor open` / adopt / claim.
613
+ export function orchProjectForSession(sid) {
614
+ try {
615
+ if (!sid) return "";
616
+ for (const line of readFileSync(orchSessionsPath(), "utf8").split("\n")) {
617
+ const [p, s] = line.split("\t");
618
+ if (p && s && s.trim() === String(sid).trim()) return p.trim();
619
+ }
620
+ } catch {}
621
+ return "";
622
+ }
623
+
624
+ // The one resolver. Returns { project, projectDir, pane, surface }:
625
+ // pane — the session's own pane id ("" when it is not a hosted pane)
626
+ // surface — "pane" (the baton MUST take the pane leg) or "window" (today's behavior)
627
+ // project — the REGISTERED project name; a subfolder cwd never renames it
628
+ export function resolveHandoffSurface({ projectDir, sessionId, env = process.env } = {}) {
629
+ const dir = projectDir || env.CLAUDE_PROJECT_DIR || process.cwd();
630
+ const badge = String(env.TRANTOR_ORCH || "").trim();
631
+ let project = "";
632
+ if (badge && badge !== "1") project = badge; // `trantor open` badge carries the name
633
+ if (!project && env.RELAY_PROJECT) project = String(env.RELAY_PROJECT).trim();
634
+ if (!project) project = orchProjectForSession(sessionId);
635
+ if (!project) project = resolveProject(dir); // last resort: cwd (git-root aware)
636
+ return { project, projectDir: dir, pane: paneSurfaceEnv(env), surface: paneSurfaceEnv(env) ? "pane" : "window" };
637
+ }
638
+
588
639
  export function spawnFresh(projectDir) {
589
640
  try {
590
641
  if (process.platform !== "darwin" || spawnSuppressed()) return false;
642
+ if (paneSurfaceEnv()) return false; // #6074: pane sessions never open windows
591
643
  if (hasOrchPane(basename(projectDir))) return false; // the pane is the successor surface (#5509)
592
644
  const script = join(HERE, "..", "..", "bin", "open-session.sh");
593
645
  if (!existsSync(script)) return false;
@@ -624,11 +676,15 @@ export function resolveOriginalWindow() {
624
676
  // window machinery (resolve/arm-close) is deliberately absent here: there is no window to close,
625
677
  // and the old path's answer ("spawn disabled — open manually") left the operator doing the
626
678
  // machine's job by hand.
627
- export function spawnPaneBaton(projectDir, handoffFile) {
679
+ export function spawnPaneBaton(projectDir, handoffFile, paneId = "") {
628
680
  try {
629
681
  const script = join(HERE, "..", "..", "bin", "baton-pane.mjs");
630
682
  if (!existsSync(script)) return false;
631
- const child = spawn(process.execPath, [script, "--project", projectDir, "--handoff", handoffFile], { detached: true, stdio: "ignore" });
683
+ // #6074: when the dying session KNOWS its pane (HERDR_PANE_ID), pass it — the driver must
684
+ // replace THAT pane, not guess one from a crew-windows row keyed by a cwd-derived name.
685
+ const args = [script, "--project", projectDir, "--handoff", handoffFile];
686
+ if (paneId) args.push("--pane", paneId);
687
+ const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
632
688
  child.unref();
633
689
  return true;
634
690
  } catch { return false; }
@@ -643,15 +699,30 @@ export function spawnPaneBaton(projectDir, handoffFile) {
643
699
  // regression-tested headlessly.
644
700
  export function spawnBaton({ projectDir, handoffFile, conf = readConfig(),
645
701
  _resolveWindow = resolveOriginalWindow, _spawnFresh = spawnFresh, _armClose = armBatonClose,
646
- _hasPane = hasOrchPane, _spawnPane = spawnPaneBaton }) {
702
+ _hasPane = hasOrchPane, _spawnPane = spawnPaneBaton, _env = process.env }) {
647
703
  // A DRILL MUST BE ABLE TO SAY NO. There was no such switch, so exercising the baton path in a
648
704
  // test opened real Terminal windows running real `claude` sessions in temp directories the test
649
705
  // then deleted, each parked on a "do you trust this folder?" prompt. Five of them were found by
650
706
  // the operator on 2026-08-24. A code path that spawns windows needs an off switch, or it cannot
651
- // be tested honestly and someone will fake one that does not exist.
652
- if (spawnSuppressed() || conf.batonSpawn === false) {
707
+ // be tested honestly and someone will fake one that does not exist. The check reads the REAL env
708
+ // (spawnSuppressed) AND the injected one (_env) — a crew runner exports TRANTOR_NO_*_SPAWN for
709
+ // its seats, and a drill that inherits that must still be able to exercise each branch by
710
+ // passing its own env, not by mutating the runner's.
711
+ const suppressed = spawnSuppressed()
712
+ || String(_env.TRANTOR_NO_HANDOFF_SPAWN || "") === "1" || String(_env.TRANTOR_NO_BATON_SPAWN || "") === "1";
713
+ if (suppressed || conf.batonSpawn === false) {
653
714
  return { spawned: false, armed: false, windowId: "", suppressed: true };
654
715
  }
716
+ // #6074, checked FIRST: the session's OWN env is the truth about where it lives. HERDR_PANE_ID
717
+ // set means the pane leg, keyed by THAT pane id — regardless of cwd or project name — and the
718
+ // whole window machinery (resolve + spawn + arm-close) is forbidden here: a pane session has no
719
+ // Terminal window, so the front-window fallback can only ever pick a stranger's (the witnessed
720
+ // crebral-scribe/ios incident armed a close on a window this session never owned).
721
+ const paneId = paneSurfaceEnv(_env);
722
+ if (paneId) {
723
+ const spawned = _spawnPane(projectDir, handoffFile, paneId);
724
+ return { spawned, armed: false, windowId: "", pane: true, paneId };
725
+ }
655
726
  // Hosted pane (#5643): the pane IS the successor surface — no window is resolved, spawned, or
656
727
  // armed for closing. The detached driver replaces the session at the turn boundary.
657
728
  if (_hasPane(basename(projectDir))) {
@@ -221,8 +221,15 @@ try {
221
221
  // instead of silently routing to the default.
222
222
  const { url, via: hubVia } = resolveHubInfo(project);
223
223
 
224
- // register self + post an initial presence status (no LLM turn — instant for others to read)
225
- await jpost(`${url}/register`, { session, project, status: `active in ${project}` }, session).catch(() => {});
224
+ // register self + post an initial presence status (no LLM turn — instant for others to read).
225
+ // kind "orch" when `trantor open` badged THIS session as the project's orchestrator pane
226
+ // (#6075): the peer row's kind is the hub's own record of what a session is — the overseer's
227
+ // declared-crew exemption reads it, and on the remote hub there is no local crew-windows.txt.
228
+ // Strict match (badge === project), same rule the doctrine gate below uses; a badge of "1"
229
+ // carries no name, so it stamps nothing. Absent kind is preserved by /register, so the MCP's
230
+ // kindless heartbeats never erase this.
231
+ const orchBadge = process.env.TRANTOR_ORCH || "";
232
+ await jpost(`${url}/register`, { session, project, status: `active in ${project}`, ...(orchBadge === project ? { kind: "orch" } : {}) }, session).catch(() => {});
226
233
 
227
234
  // fetch roster of OTHER online sessions
228
235
  let peers = [], hubAnswered = false;
@@ -21,12 +21,12 @@
21
21
  // * Only claim delivery once we have actually decided to surface it (peek first). Marking a message
22
22
  // delivered and then letting the stop through would hide it from the waker too — a silent hole.
23
23
  // * Any error, or a hub that is down -> allow the stop. Never trap a session because of us.
24
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
24
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, renameSync } from "node:fs";
25
25
  import { join, dirname } from "node:path";
26
26
  import { spawn } from "node:child_process";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { homedir } from "node:os";
29
- import { resolveProject, hostId, handoffDir } from "../lib/project.mjs";
29
+ import { resolveProject, hostId, handoffDir, busDir } from "../lib/project.mjs";
30
30
  import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
31
31
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
32
32
  import { readArm, clearArm, markHandedOff, appendHandoffState } from "./lib/handoff.mjs";
@@ -71,6 +71,32 @@ const OVERDUE_MS = (() => {
71
71
  return Number.isFinite(n) && n >= 0 ? n : 10 * 60 * 1000;
72
72
  })();
73
73
 
74
+ function stalledSeenPath(session) {
75
+ const safe = String(session).replace(/[^A-Za-z0-9_.-]/g, "_");
76
+ return join(busDir(), `stop-stalled-seen-${safe}.json`);
77
+ }
78
+
79
+ function readStalledSeen(session) {
80
+ try {
81
+ const ids = JSON.parse(readFileSync(stalledSeenPath(session), "utf8"));
82
+ return new Set(Array.isArray(ids) ? ids.map(String) : []);
83
+ } catch { return new Set(); }
84
+ }
85
+
86
+ function writeStalledSeen(session, seen) {
87
+ const path = stalledSeenPath(session);
88
+ const tmp = `${path}.${process.pid}.tmp`;
89
+ try {
90
+ mkdirSync(dirname(path), { recursive: true });
91
+ writeFileSync(tmp, JSON.stringify([...seen]) + "\n");
92
+ renameSync(tmp, path);
93
+ return true;
94
+ } catch {
95
+ try { unlinkSync(tmp); } catch {}
96
+ return false;
97
+ }
98
+ }
99
+
74
100
  async function stalledContractCheck({ session, project, instanceId }) {
75
101
  if (process.env.RELAY_STOP_CONTRACTS === "0") return allow();
76
102
  let contracts = [];
@@ -89,8 +115,19 @@ async function stalledContractCheck({ session, project, instanceId }) {
89
115
  const abandoned = contracts.filter(c => c.disposition === "abandoned");
90
116
  if (!stalled.length) return allow();
91
117
 
118
+ const seen = readStalledSeen(session);
119
+ const fresh = stalled.filter(c => !seen.has(String(c.id)));
120
+ if (!fresh.length) {
121
+ process.stderr.write(`[trantor] ${stalled.length} stalled contract(s) already surfaced for ${sanitize(session)}; allowing stop\n`);
122
+ return allow();
123
+ }
124
+ for (const contract of stalled) seen.add(String(contract.id));
125
+ // Persist before blocking: if the harness stops this process immediately after reading stdout,
126
+ // the next Stop must still know this exact stalled episode was already surfaced.
127
+ if (!writeStalledSeen(session, seen)) return allow();
128
+
92
129
  const mins = (ms) => (ms >= 60000 ? `${Math.round(ms / 60000)}m` : `${Math.round(ms / 1000)}s`);
93
- const lines = stalled.slice(0, 6).map(c => {
130
+ const lines = fresh.slice(0, 6).map(c => {
94
131
  const health = c.assigneeOnline ? `online, status "${sanitize(c.assigneeStatus || "?")}"`
95
132
  : c.assigneeLastSeenMs == null ? "never seen on the bus"
96
133
  : `LAST SEEN ${mins(c.assigneeLastSeenMs)} ago`;
@@ -104,7 +141,7 @@ async function stalledContractCheck({ session, project, instanceId }) {
104
141
  : "";
105
142
 
106
143
  const reason =
107
- `You dispatched ${stalled.length} contract(s) that have gone quiet, and you were about to go idle:\n` +
144
+ `You dispatched ${fresh.length} NEW contract(s) that have gone quiet, and you were about to go idle:\n` +
108
145
  lines + ghosts + `\n\n` +
109
146
  `Do not just wait, and do not ask the human to check. Use relay_contracts to see everything you are owed, ` +
110
147
  `relay_peers to see whether the seat is alive, and relay_send to ask it directly. If a seat is down, say so and ` +
package/hub.mjs CHANGED
@@ -6,12 +6,13 @@
6
6
  // private network, or add auth first. See "Always-on / remote hub" in the README (roadmap).
7
7
  import http from "node:http";
8
8
  import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs";
9
- import { homedir } from "node:os";
9
+ import { homedir, hostname } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { timingSafeEqual, randomBytes } from "node:crypto";
12
12
  import { verifyRequest, verifyEndorsement, publicView } from "./lib/identity.mjs";
13
13
  import { DEFAULT_ORG } from "./lib/store-contract.mjs";
14
14
  import { assertNoSecrets } from "./lib/scrub.mjs";
15
+ import { createPersistHealth } from "./lib/persist-health.mjs";
15
16
 
16
17
  const PORT = Number(process.env.RELAY_PORT || 4477);
17
18
  const HOST = process.env.RELAY_HOST || "127.0.0.1";
@@ -125,6 +126,7 @@ function emptyState() {
125
126
  const CARD_LOG_MAX = 40;
126
127
  const CARD_LOG_TEXT_MAX = 2000;
127
128
  const CARD_LOG_BY_MAX = 120;
129
+ const stripNulText = (value) => String(value ?? "").replace(/\u0000/g, "");
128
130
  function normalizeTaskLog(t) {
129
131
  if (!Array.isArray(t.log)) { if (t.log !== undefined) delete t.log; return false; }
130
132
  const before = JSON.stringify(t.log);
@@ -133,7 +135,7 @@ function normalizeTaskLog(t) {
133
135
  .map(e => ({
134
136
  ts: Number.isFinite(Number(e.ts)) && Number(e.ts) > 0 ? Math.floor(Number(e.ts)) : Date.now(),
135
137
  by: String(e.by || "").slice(0, CARD_LOG_BY_MAX),
136
- text: String(e.text || "").slice(0, CARD_LOG_TEXT_MAX),
138
+ text: stripNulText(e.text).slice(0, CARD_LOG_TEXT_MAX),
137
139
  }))
138
140
  .slice(-CARD_LOG_MAX);
139
141
  if (!t.log.length) delete t.log;
@@ -144,7 +146,7 @@ function appendTaskLog(t, by, text, ts = Date.now()) {
144
146
  const entry = {
145
147
  ts: Number.isFinite(Number(ts)) && Number(ts) > 0 ? Math.floor(Number(ts)) : Date.now(),
146
148
  by: String(by || "").slice(0, CARD_LOG_BY_MAX),
147
- text: String(text).slice(0, CARD_LOG_TEXT_MAX),
149
+ text: stripNulText(text).slice(0, CARD_LOG_TEXT_MAX),
148
150
  };
149
151
  const log = Array.isArray(t.log) ? t.log : [];
150
152
  log.push(entry);
@@ -251,6 +253,11 @@ let dirty = false;
251
253
  // `cardEvents` key — so downgrading to a pre-0.17.54 hub still boots with its full TIMELINE
252
254
  // instead of a silently empty history. Cheap insurance on a live hub; drop the mirror later.
253
255
  let persisting = false;
256
+ const persistHealth = createPersistHealth({
257
+ baseMs: process.env.RELAY_PERSIST_RETRY_BASE_MS,
258
+ maxMs: process.env.RELAY_PERSIST_RETRY_MAX_MS,
259
+ logIntervalMs: process.env.RELAY_PERSIST_LOG_INTERVAL_MS,
260
+ });
254
261
  const snapshotState = () => JSON.parse(JSON.stringify({ ...state, cardEvents: state.events.filter(e => ["created", "moved", "updated"].includes(e?.type)) }));
255
262
  // This hub's writer id: saveDelta stamps it into every NOTIFY so we can tell our own change
256
263
  // notifications apart from a second writer's (importer, admin psql, another hub instance).
@@ -260,25 +267,38 @@ const HUB_SRC = `hub-${process.pid}-${randomBytes(4).toString("hex")}`;
260
267
  // survive our persist ticks (the old saveSnapshot wholesale delete+rewrite destroyed them).
261
268
  let lastPersisted = durableStore ? snapshotState() : null;
262
269
  if (runTaskBootMigrations()) dirty = true;
270
+ const recordPersistFailure = (kind, error) => {
271
+ dirty = true;
272
+ const failure = persistHealth.failed(error);
273
+ if (failure.shouldLog) {
274
+ process.stderr.write(`[trantor] ${kind} persist failing: ${failure.health.retries} retries over ${failure.health.failingSinceMs}ms; last error: ${failure.health.lastError}\n`);
275
+ }
276
+ };
263
277
  const persist = () => {
264
- if (!dirty || persisting) return;
278
+ if (!dirty || persisting || !persistHealth.canAttempt()) return;
265
279
  if (durableStore) {
266
280
  const snapshot = snapshotState();
267
281
  dirty = false; persisting = true;
268
282
  durableStore.saveDelta(ORG_ID, lastPersisted, snapshot, { src: HUB_SRC }).then(() => {
269
283
  lastPersisted = snapshot;
284
+ persistHealth.succeeded();
270
285
  }).catch(e => {
271
- dirty = true;
272
- process.stderr.write(`[trantor] Postgres persist failed: ${e.message || e}\n`);
286
+ recordPersistFailure("Postgres", e);
273
287
  }).finally(() => {
274
288
  persisting = false;
275
- if (dirty) persist();
276
289
  });
277
290
  return;
278
291
  }
279
- try { writeFileSync(DATA, JSON.stringify(snapshotState())); dirty = false; } catch {}
292
+ try {
293
+ writeFileSync(DATA, JSON.stringify(snapshotState()));
294
+ dirty = false;
295
+ persistHealth.succeeded();
296
+ } catch (e) {
297
+ recordPersistFailure("JSON", e);
298
+ }
280
299
  };
281
- setInterval(persist, 1000).unref?.();
300
+ const persistTickMs = Math.min(1000, Math.max(10, Number(process.env.RELAY_PERSIST_RETRY_BASE_MS) || 1000));
301
+ setInterval(persist, persistTickMs).unref?.();
282
302
 
283
303
  // --- reload on external change (the boot-cache fix) -------------------------------------------
284
304
  // A NOTIFY from any writer that isn't us means Postgres has rows our in-memory projection has
@@ -320,6 +340,14 @@ async function reloadFromStore() {
320
340
  // not spam the feed; at level >= 3 a file-conflict opens a verify gate (the go/no-go primitive).
321
341
  let _overseer = null;
322
342
  import("./lib/overseer.mjs").then(m => { _overseer = m; }).catch(() => {});
343
+ // #5760: the same-project episode rule (lib/same-project.mjs, pure) — lazy like the detector, so
344
+ // a hub booted before the module lands still runs (the same-project branch fails QUIET until it
345
+ // arrives: a missing rule must never re-instate the hourly metronome).
346
+ let _sameProject = null;
347
+ import("./lib/same-project.mjs").then(m => { _sameProject = m; }).catch(() => {});
348
+ // This hub runs on the operator's machine, so the machine's own sessions ("<host>:<project>") ARE
349
+ // the orchestrator side of every declared crew.
350
+ const HOST_NAME = hostname().split(".")[0];
323
351
  const OVERSEER_TICK_MS = Number(process.env.RELAY_OVERSEER_TICK_MS || 30 * 1000);
324
352
  // How long a condition must be ABSENT before we consider the episode over. This is NOT a re-warn
325
353
  // timer: see overseerTick.
@@ -353,6 +381,37 @@ function overseerInputs() {
353
381
  now: now(),
354
382
  };
355
383
  }
384
+
385
+ // --- #5760: the same-project warning is an EPISODE keyed by the MEMBER SET -------------------
386
+ // The night of 08-31 the same-project DM re-fired hourly for a membership that never changed and
387
+ // woke every seat into metered chatter turns. The rule (lib/same-project.mjs, pure) decides
388
+ // fire-or-not from (previous set, current set, declared crew, last-fired-at): a declared crew is
389
+ // the NORMAL state of a project and is not a collision at all; a set that never changed re-warns
390
+ // never. The warn itself rides the SAME episode machinery as every other kind (#5350: one warn at
391
+ // open, newcomer-only intros while standing, a genuine clear ends it) — the record below only
392
+ // feeds the pure rule the set it judged last, so "unchanged" is one hash comparison and the
393
+ // record line reports DURATION ("same-project for 6h"), never a count of warnings.
394
+ const sameProjectFired = new Map(); // project -> { hash, sessions, ts } — the set as of the last verdict
395
+
396
+ // The declared crew: HUB state, never a file on the operator's machine (#6075). The production
397
+ // hub runs on netcup, where ~/.agent-bus/crew-windows.txt does not exist — the file describes the
398
+ // OPERATOR'S machine (it is written by `trantor up` there), so on the remote hub the old reader
399
+ // found nothing and every same-project set looked like intruders: the crew-only exemption simply
400
+ // never held remotely. What the hub itself knows is the peer row's `kind` (#6148): "agent" is a
401
+ // crew seat — crew-runner stamps it on every /register its seats make — and "orch" is the
402
+ // project's orchestrator pane (sessionstart stamps it when TRANTOR_ORCH names this project).
403
+ // The HOST_NAME exemption is gone with the file: the hub's hostname is the hub machine's
404
+ // (netcup), never the operator's, so `<HOST_NAME>:<project>` exempted a session that cannot
405
+ // exist. Genesis is deliberately NOT crew (#6068: a bookkeeping identity, not a seat).
406
+ function declaredCrewFor(project) {
407
+ const crew = new Set();
408
+ for (const [sid, p] of Object.entries(state.peers)) {
409
+ if ((p.project || "") !== project) continue;
410
+ if (p.kind === "agent" || p.kind === "orch") crew.add(sid);
411
+ }
412
+ return [...crew];
413
+ }
414
+
356
415
  function overseerTick() {
357
416
  if (!_overseer?.detectCollisions) return;
358
417
  let collisions = [];
@@ -365,8 +424,8 @@ function overseerTick() {
365
424
  // sessions to "coordinate over the bus" is useless if neither knows the other's id, and the
366
425
  // warning alone went only to the duty seat and the log — so coordination needed a human to carry
367
426
  // the ids across. Shared by the episode-start branch (all parties) and the standing branch
368
- // (newcomers only): existing members never re-hear it, so a standing condition must not re-wake
369
- // every party every tick.
427
+ // (newcomers only, same-project included): existing members never
428
+ // re-hear it, so a standing condition must not re-wake every party every tick.
370
429
  const intro = (c, me, others) => {
371
430
  const rest = others.filter(p => p !== me);
372
431
  if (rest.length === 0) return;
@@ -374,7 +433,58 @@ function overseerTick() {
374
433
  `🤝 OVERSEER ${c.kind}: you and ${rest.join(", ")} are working on overlapping ground${c.files?.length ? ` (${c.files.slice(0, 3).join(", ")})` : ""}. ${c.detail || ""} Coordinate directly — relay_send to ${rest[0]} — and split the work between you. No human needs to relay this.`,
375
434
  c.project);
376
435
  };
436
+ // #5760: same-project sets judged crew-only are dropped entirely — the normal state of a
437
+ // project, not a collision — so not even the context feed narrates them.
438
+ const kept = [];
377
439
  for (const c of collisions) {
440
+ // #5760: same-project gets the pure episode rule (lib/same-project.mjs) ON TOP of the shared
441
+ // episode machinery below: a crew-only set is not a collision at all (dropped — no warn, no
442
+ // context, no state); a standing set re-warns never (a liveness flap replays the SAME set —
443
+ // 08-31's metronome — and must stay silent, only genuine newcomers hear the intro once); and
444
+ // the record line reports DURATION. Without the rule module this branch is invisible and
445
+ // same-project rides the generic loop exactly as before — a missing rule must never
446
+ // re-instate the hourly metronome, so the fallback is the pre-#5760 behavior, never stricter.
447
+ if (c.kind === "same-project-sessions" && _sameProject?.sameProjectDecision) {
448
+ const prior = sameProjectFired.get(c.project) || null;
449
+ const d = _sameProject.sameProjectDecision({
450
+ previous: prior?.sessions ?? null,
451
+ current: c.sessions,
452
+ declaredCrew: declaredCrewFor(c.project),
453
+ lastFiredAt: prior?.ts ?? null,
454
+ now: t,
455
+ });
456
+ if (d.reason === "crew-only") continue;
457
+ const key = `${c.project} ${c.kind}`;
458
+ c.key = key;
459
+ seen.add(key);
460
+ kept.push(c);
461
+ const parties = [...new Set(c.sessions || [])].filter(s => s && s !== DUTY_SESSION);
462
+ const standing = overseerActive.get(key);
463
+ if (standing) {
464
+ // The episode HOLDS: no new warn, whoever was introed once is never re-heard.
465
+ standing.lastTick = t;
466
+ c.since = standing.since;
467
+ if (_sameProject.durationLabel) c.detail = `${c.detail || ""} (same-project for ${_sameProject.durationLabel(t - standing.since)})`.trim();
468
+ for (const me of parties) if (!standing.sessions.has(me)) intro(c, me, parties);
469
+ for (const me of parties) standing.sessions.add(me);
470
+ // The record tracks the live membership (ts stays at the last warn) so a later open
471
+ // judges the true previous set and can say how long the old one held.
472
+ if (d.fire && prior) sameProjectFired.set(c.project, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: prior.ts });
473
+ continue;
474
+ }
475
+ // The episode OPENS and the pure rule said fire — first sighting, or a membership change
476
+ // on a remembered set; the record line states how long the previous state held.
477
+ overseerActive.set(key, { since: t, lastTick: t, sessions: new Set(parties) });
478
+ c.since = t;
479
+ if (d.reason === "membership-changed") c.detail = `${c.detail || ""} (same-project for ${_sameProject.durationLabel(d.durationMs)})`.trim();
480
+ sameProjectFired.set(c.project, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: t });
481
+ appendEvent("overseer.warn", c.project, "overseer",
482
+ { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
483
+ if (DUTY_SESSION) hubSend(DUTY_SESSION, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
484
+ if (parties.length > 1) for (const me of parties) intro(c, me, parties);
485
+ continue;
486
+ }
487
+ kept.push(c);
378
488
  // Episode identity is the CONDITION (project+kind+files), never the session list (#5350):
379
489
  // membership is volatile — a third seat bouncing in and out of a standing collision minted a
380
490
  // fresh key, so a fresh episode, so a fresh warn (+ duty wake + party intros) per permutation.
@@ -413,9 +523,14 @@ function overseerTick() {
413
523
  // Episode end: a condition gone for the whole clear window is over, so a LATER recurrence is a
414
524
  // new episode and warns again. Without this the map would grow forever and nothing could re-fire.
415
525
  for (const [k, v] of overseerActive) {
416
- if (!seen.has(k) && t - v.lastTick > OVERSEER_CLEAR_MS) overseerActive.delete(k);
526
+ if (!seen.has(k) && t - v.lastTick > OVERSEER_CLEAR_MS) {
527
+ overseerActive.delete(k);
528
+ // #5760: the same-project verdict record dies WITH its episode — a set that returns after a
529
+ // genuine clear is a new episode (it warns again, first sighting), not the old one continuing.
530
+ if (k.endsWith(" same-project-sessions")) sameProjectFired.delete(k.slice(0, -" same-project-sessions".length));
531
+ }
417
532
  }
418
- overseerLastCollisions = collisions;
533
+ overseerLastCollisions = kept;
419
534
  }
420
535
  setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
421
536
  // setInterval waits a FULL period before its first call, so for 30s after every restart the watcher
@@ -1173,6 +1288,22 @@ function markDelivered(session, upTo) {
1173
1288
  function pushToStreams(msg) {
1174
1289
  for (const s of streams) if (deliverable(msg, s.session)) { try { s.res.write(`data: ${JSON.stringify(msg)}\n\n`); } catch {} }
1175
1290
  }
1291
+ // A live runner can outlast the durable snapshot it was polling. If its cursor is now beyond the
1292
+ // message high-water mark, echoing that impossible value leaves it deaf forever. Clamp it to the
1293
+ // current tip and say explicitly that time moved backwards so clients can adopt the lower cursor.
1294
+ function inboxWindow(value) {
1295
+ const parsed = Number(value || 0);
1296
+ const requested = Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
1297
+ const tip = Math.max(Number(state.seq || 0), Number(state.messages[state.messages.length - 1]?.id || 0));
1298
+ const rewound = requested > tip;
1299
+ return { since: rewound ? tip : requested, tip, rewound };
1300
+ }
1301
+ function inboxResponse(auth, messages, cursor, rewound = false) {
1302
+ const response = { messages, cursor };
1303
+ if (rewound) response.rewound = true;
1304
+ if (auth?.superseded) response.superseded = true;
1305
+ return response;
1306
+ }
1176
1307
  // Live push for the FEED. Sent ONLY to streams that opted in with /stream?events=1, and as a NAMED
1177
1308
  // SSE event ("event: ev") so an existing consumer's default onmessage handler — which expects a bus
1178
1309
  // message and nothing else — can never see it. Backwards-safe by construction.
@@ -1390,7 +1521,9 @@ const server = http.createServer(async (req, res) => {
1390
1521
  // WHO is this, really: the LLM brand + the exact model currently loaded. In-memory like the
1391
1522
  // rest of presence — the next heartbeat re-supplies it after a restart.
1392
1523
  const pr = state.peers[b.session];
1393
- if (pr) { if (b.model) pr.model = String(b.model).slice(0, 80); if (b.llm) pr.llm = String(b.llm).slice(0, 40); }
1524
+ // #6148: WHAT a session is rides its peer row (kind "genesis" = the CLI's brief-poster,
1525
+ // "agent" = a crew seat) — /peers hands it to the app so the seat strip can tell them apart.
1526
+ if (pr) { if (b.model) pr.model = String(b.model).slice(0, 80); if (b.llm) pr.llm = String(b.llm).slice(0, 40); if (b.kind) pr.kind = String(b.kind).slice(0, 40); }
1394
1527
  return json(res, 200, { ok: true, session: b.session, peers: Object.keys(state.peers) });
1395
1528
  }
1396
1529
  if (req.method === "POST" && P === "/status") { const b = await body(req); touch(b.session, b.status ?? "", b.project, b.hookVersion, auth); return json(res, 200, { ok: true }); }
@@ -1558,8 +1691,29 @@ const server = http.createServer(async (req, res) => {
1558
1691
  const inflight = [...fileClaims.values()].filter(c => c.project === proj)
1559
1692
  .map(c => ({ file: c.file, session: c.session, agoSec: Math.round((now() - c.ts) / 1000) }));
1560
1693
  let warnings = [];
1561
- try { warnings = (_overseer?.detectCollisions ? _overseer.detectCollisions(overseerInputs()) : [])
1562
- .filter(c => c.project === proj || linked.has(c.project)); } catch {}
1694
+ try {
1695
+ warnings = (_overseer?.detectCollisions ? _overseer.detectCollisions(overseerInputs()) : [])
1696
+ .filter(c => c.project === proj || linked.has(c.project));
1697
+ // #5760: a declared crew is the NORMAL state of a project, not a collision — the tick
1698
+ // loop drops crew-only sets before they ever become episodes, and this live view must
1699
+ // agree with it: the SessionStart hook narrates exactly these lines, so a crew-only leak
1700
+ // here would re-wake every booting seat into a metered turn for no membership change.
1701
+ warnings = warnings.filter(c => !(c.kind === "same-project-sessions" && _sameProject?.sameProjectDecision &&
1702
+ _sameProject.sameProjectDecision({
1703
+ current: c.sessions,
1704
+ declaredCrew: declaredCrewFor(c.project),
1705
+ now: now(),
1706
+ }).reason === "crew-only"));
1707
+ // The record line reports DURATION ("same-project for 6h"), never a count of warnings.
1708
+ for (const c of warnings) {
1709
+ if (c.kind !== "same-project-sessions") continue;
1710
+ const ep = overseerActive.get(`${c.project} same-project-sessions`);
1711
+ if (ep) {
1712
+ c.since = ep.since;
1713
+ if (_sameProject?.durationLabel) c.detail = `${c.detail || ""} (same-project for ${_sameProject.durationLabel(now() - ep.since)})`.trim();
1714
+ }
1715
+ }
1716
+ } catch {}
1563
1717
  return json(res, 200, { level, links: links.map(l => ({ projects: l.projects, reason: l.reason })), peers: peersOut, inflight, warnings });
1564
1718
  }
1565
1719
  // Supersession (docs/INSTANCE-KEYS-CONTRACT.md): EXPLICIT, never automatic — the baton-claim
@@ -1639,7 +1793,7 @@ const server = http.createServer(async (req, res) => {
1639
1793
  const peerRows = filterDiscoverable(auth, Object.entries(state.peers), ([, v]) => v.project || "");
1640
1794
  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 || "",
1641
1795
  pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "",
1642
- llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) })) });
1796
+ kind: v.kind || v.identity?.kind || "", llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) })) });
1643
1797
  }
1644
1798
  // --- Provider balances (prepaid credit) ---
1645
1799
  // The hub runs under launchd with no provider keys, so it can't fetch balances itself. Env-having
@@ -1766,6 +1920,8 @@ const server = http.createServer(async (req, res) => {
1766
1920
  // --- Kanban tasks ---
1767
1921
  if (req.method === "POST" && P === "/task") { // create a card
1768
1922
  const b = await body(req); touch(b.by, undefined, b.project, undefined, auth);
1923
+ if (b.title !== undefined) b.title = stripNulText(b.title);
1924
+ if (b.note !== undefined) b.note = stripNulText(b.note);
1769
1925
  const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
1770
1926
  // optional historical ts (backfill from git/import) — accept a past epoch-ms; else now().
1771
1927
  const ts0 = (Number.isFinite(b.ts) && b.ts > 0 && b.ts <= now() + 864e5) ? Math.floor(b.ts) : now();
@@ -1919,6 +2075,8 @@ const server = http.createServer(async (req, res) => {
1919
2075
  if (req.method === "POST" && P === "/task/update") { // move/edit a card
1920
2076
  const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
1921
2077
  if (!t) return json(res, 404, { error: "no such task" });
2078
+ if (b.title !== undefined) b.title = stripNulText(b.title);
2079
+ if (b.note !== undefined) b.note = stripNulText(b.note);
1922
2080
  // Board integrity (#5406): a card can never change hands silently. The assignee is frozen once
1923
2081
  // set; a mutation is legitimate only as a HANDOFF (the current assignee reassigning to someone
1924
2082
  // else) or an EXPLICIT reassign (reassign:true — e.g. the orchestrator re-routing work after a
@@ -2279,7 +2437,7 @@ const server = http.createServer(async (req, res) => {
2279
2437
  // --- lessons: cross-agent learning from failures. scope = "global" or an agent brand ("kimi") ---
2280
2438
  if (req.method === "POST" && P === "/lesson") {
2281
2439
  const b = await body(req);
2282
- const text = String(b.text || "").trim().slice(0, 400);
2440
+ const text = stripNulText(b.text).trim().slice(0, 400);
2283
2441
  const scope = String(b.scope || "global").toLowerCase().slice(0, 40);
2284
2442
  if (!text) return json(res, 400, { error: "text required" });
2285
2443
  if (state.lessons.some(l => l.scope === scope && l.text === text)) return json(res, 200, { ok: true, dedup: true });
@@ -2614,7 +2772,7 @@ const server = http.createServer(async (req, res) => {
2614
2772
  }
2615
2773
  if (req.method === "POST" && P === "/send") {
2616
2774
  const b = await body(req);
2617
- const text = String(b.text ?? "");
2775
+ const text = stripNulText(b.text);
2618
2776
  if (!b.from || !text.trim()) return json(res, 400, { error: "from and non-empty text required" });
2619
2777
  const secretCheck = assertNoSecrets(text);
2620
2778
  if (!secretCheck.ok) return json(res, 400, { error: "secret detected", kinds: secretCheck.kinds || [] });
@@ -2693,7 +2851,8 @@ const server = http.createServer(async (req, res) => {
2693
2851
 
2694
2852
  if (req.method === "GET" && P === "/inbox") {
2695
2853
  if (!canUseInboxSession(auth, q.session)) return json(res, 403, { error: "forbidden" });
2696
- touch(q.session, undefined, undefined, undefined, auth); const since = Number(q.since || 0);
2854
+ touch(q.session, undefined, undefined, undefined, auth); const window = inboxWindow(q.since);
2855
+ const { since, rewound } = window;
2697
2856
  const msgs = state.messages.filter(m => m.id > since && deliverable(m, q.session) && inboxReadable(auth, m, q.session));
2698
2857
  const cursor = msgs.length ? msgs[msgs.length - 1].id : since;
2699
2858
  // peek=1 -> LOOK without claiming delivery. The Stop hook has to ask "is anything waiting?" before
@@ -2703,16 +2862,21 @@ const server = http.createServer(async (req, res) => {
2703
2862
  if (q.peek !== "1") markDelivered(q.session, cursor);
2704
2863
  // superseded (instance-keys contract): a baton twin that lost the claim learns it HERE, via
2705
2864
  // its own read — its hooks turn this into a stand-down note for the model. Never a block.
2706
- return json(res, 200, auth?.superseded ? { messages: msgs, cursor, superseded: true } : { messages: msgs, cursor });
2865
+ return json(res, 200, inboxResponse(auth, msgs, cursor, rewound));
2707
2866
  }
2708
2867
  if (req.method === "GET" && P === "/poll") {
2709
2868
  if (!canUseInboxSession(auth, q.session)) return json(res, 403, { error: "forbidden" });
2710
- touch(q.session, undefined, undefined, undefined, auth); const since = Number(q.since || 0);
2869
+ touch(q.session, undefined, undefined, undefined, auth); const window = inboxWindow(q.since);
2870
+ const { since, rewound } = window;
2871
+ if (rewound) {
2872
+ markDelivered(q.session, window.tip);
2873
+ return json(res, 200, inboxResponse(auth, [], window.tip, true));
2874
+ }
2711
2875
  const waitMs = Math.min(Number(q.wait || 25), 290) * 1000; // allow long idle-park
2712
2876
  const deadline = now() + waitMs;
2713
2877
  const tick = () => {
2714
2878
  const msgs = state.messages.filter(m => m.id > since && deliverable(m, q.session) && inboxReadable(auth, m, q.session));
2715
- if (msgs.length || now() >= deadline) { touch(q.session, undefined, undefined, undefined, auth); const cursor = msgs.length ? msgs[msgs.length - 1].id : since; markDelivered(q.session, cursor); return json(res, 200, auth?.superseded ? { messages: msgs, cursor, superseded: true } : { messages: msgs, cursor }); }
2879
+ if (msgs.length || now() >= deadline) { touch(q.session, undefined, undefined, undefined, auth); const cursor = msgs.length ? msgs[msgs.length - 1].id : since; markDelivered(q.session, cursor); return json(res, 200, inboxResponse(auth, msgs, cursor)); }
2716
2880
  setTimeout(tick, 300);
2717
2881
  };
2718
2882
  return tick();
@@ -2738,6 +2902,7 @@ const server = http.createServer(async (req, res) => {
2738
2902
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); return res.end(UI || "<h1>trantor</h1><p>dashboard unavailable</p>");
2739
2903
  }
2740
2904
  if (P === "/health") return json(res, 200, { ok: true, authMode: AUTH_MODE, peers: Object.keys(state.peers).length, messages: state.messages.length, streams: streams.length,
2905
+ persist: persistHealth.view(),
2741
2906
  // #5686: duty liveness rides /health so the app's Home strip and doctor read one truth.
2742
2907
  duty: { ...dutyLiveness(), darkSinceMs: dutyDarkSince ? now() - dutyDarkSince : 0, queuedEscalations: dutyQueuedEscalations() } });
2743
2908
  json(res, 404, { error: "not found" });
package/lib/enroll.mjs CHANGED
@@ -32,7 +32,7 @@ export function ownerIdentity() {
32
32
 
33
33
  // Returns { ok, reason }. NEVER throws and never blocks a turn — a hub that is down, or an operator
34
34
  // key that is absent, must degrade to "unenrolled" rather than take the seat with it.
35
- export async function ensureEnrolled(hubUrl, identity, project, { timeoutMs = 4000 } = {}) {
35
+ export async function ensureEnrolled(hubUrl, identity, project, { timeoutMs = 4000, kind } = {}) {
36
36
  if (!hubUrl || !identity?.pubkey) return { ok: false, reason: "no-identity" };
37
37
  try {
38
38
  // Cheapest possible probe that the hub authorises: if we are already known this is a no-op.
@@ -55,7 +55,7 @@ export async function ensureEnrolled(hubUrl, identity, project, { timeoutMs = 40
55
55
 
56
56
  const en = await sfetch(`${hubUrl}/enroll`, {
57
57
  method: "POST", headers: { "content-type": "application/json" },
58
- body: JSON.stringify({ name: identity.name, kind: identity.kind || "agent", token }),
58
+ body: JSON.stringify({ name: identity.name, kind: kind || identity.kind || "agent", token }),
59
59
  signal: AbortSignal.timeout(timeoutMs),
60
60
  }, identity);
61
61
  return en.ok ? { ok: true, reason: "enrolled" } : { ok: false, reason: `enroll-${en.status}` };