trantor 0.18.32 → 0.18.33

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.18.32",
3
+ "version": "0.18.33",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/baton.mjs CHANGED
@@ -1,16 +1,42 @@
1
1
  #!/usr/bin/env node
2
- // `trantor handoff` — one-command manual baton (auto-summary variant). Discovers the current session's
3
- // transcript, writes a whole-session handoff (auto-summary + verbatim in-flight tail), opens a fresh
4
- // self-announcing session, and closes THIS window once it takes over. Run from inside the session you
5
- // want to hand off. (The richer MODEL-authored handoff is the /trantor:handoff skill.)
6
- import { readdirSync, statSync } from "node:fs";
7
- import { join, basename } from "node:path";
2
+ // `trantor handoff` — one-command manual baton. Discovers the current session's transcript, writes
3
+ // a whole-session handoff (auto-summary + verbatim in-flight tail), opens a fresh self-announcing
4
+ // session, and closes THIS window once it takes over. Run from inside the session you want to hand
5
+ // off. (The richer MODEL-authored handoff is the /trantor:handoff skill.)
6
+ import { readdirSync, statSync, fstatSync } from "node:fs";
7
+ import { join, basename, dirname } from "node:path";
8
8
  import { homedir } from "node:os";
9
- import { resolveProject } from "../lib/project.mjs";
10
- import { writeHandoff, spawnBaton } from "../hooks/lib/handoff.mjs";
9
+ import { spawn } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+ import { writeHandoff, spawnBaton, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
11
12
 
13
+ // #6074: the skill path (write-handoff.mjs) and this CLI path must share ONE resolution of which
14
+ // project this is and where the session lives. Both call resolveHandoffSurface; the name comes
15
+ // from the session's registration (TRANTOR_ORCH / RELAY_PROJECT / orch-sessions.txt) before the
16
+ // cwd — a subfolder cwd never renames the project.
12
17
  const cwd = process.cwd();
13
- const project = resolveProject(cwd);
18
+ const resolved = resolveHandoffSurface({ projectDir: process.env.CLAUDE_PROJECT_DIR || cwd, sessionId: process.env.CLAUDE_SESSION_ID || "" });
19
+ const project = resolved.project;
20
+
21
+ // Model-authored handoffs ride THIS binary too (`trantor handoff` — always the global install's
22
+ // CURRENT code, never the plugin-cache copy a session booted with, the stale-0.18.20 bug). A piped
23
+ // stdin (a heredoc, a cat, `<<HANDOFF`) means "here is the handoff markdown" — exactly the skill's
24
+ // contract — so forward to write-handoff.mjs in the SAME package (same version, same resolution)
25
+ // and exit with its status. --latest forwards too. A true pipe is a FIFO; a TTY and /dev/null are
26
+ // character devices, so a plain `trantor handoff` typed at a prompt (or run by a hook with stdin
27
+ // at /dev/null) keeps the auto-summary behavior. Detection must NOT be `!process.stdin.isTTY` —
28
+ // that misreads /dev/null as a handoff and errors where auto-summary used to work.
29
+ function stdinIsPipe() {
30
+ try { return fstatSync(0).isFIFO(); } catch { return false; }
31
+ }
32
+ if (stdinIsPipe() || process.argv.includes("--latest")) {
33
+ const helper = join(dirname(fileURLToPath(import.meta.url)), "write-handoff.mjs");
34
+ const child = spawn(process.execPath, [helper, ...process.argv.slice(2)], { stdio: ["inherit", "inherit", "inherit"] });
35
+ child.on("exit", (c) => process.exit(c ?? 1));
36
+ child.on("error", () => process.exit(1));
37
+ } else {
38
+ autoBaton();
39
+ }
14
40
 
15
41
  // The active session's transcript = newest *.jsonl directly in this project's Claude dir
16
42
  // (~/.claude/projects/<cwd-with-slashes-as-dashes>/), excluding the subagents/ subtree.
@@ -29,20 +55,22 @@ function findTranscript() {
29
55
  return best;
30
56
  }
31
57
 
32
- const transcript = findTranscript();
33
- // The transcript's filename IS the writing session's id — record it, or an orchestrator-thread
34
- // handoff carries no writer and the baton-hold + map-follow logic in sessionstart.mjs can't fire.
35
- const sessionId = transcript ? basename(transcript, ".jsonl") : "";
36
- const { file } = writeHandoff({ projectDir: cwd, sessionId, transcript, trigger: "manual-cli", force: true }); // manual = intentional, bypass the storm guard
37
- console.log(`📋 handoff saved for ${project}: ${file}`);
38
- // --write-only: the in-app flow (#5509). The app ends the pane's session itself and reopens it
39
- // through `trantor open`, which claims this handoff a Terminal window here would be exactly the
40
- // wrong surface, so the flag writes, announces, and stops.
41
- if (process.argv.includes("--write-only")) {
42
- console.log(`🔄 write-only: no window spawned — the pane takeover (trantor open) claims it next.`);
43
- process.exit(0);
58
+ function autoBaton() {
59
+ const transcript = findTranscript();
60
+ // The transcript's filename IS the writing session's id record it, or an orchestrator-thread
61
+ // handoff carries no writer and the baton-hold + map-follow logic in sessionstart.mjs can't fire.
62
+ const sessionId = transcript ? basename(transcript, ".jsonl") : "";
63
+ const { file } = writeHandoff({ projectDir: cwd, sessionId, transcript, trigger: "manual-cli", force: true, projectName: project }); // manual = intentional, bypass the storm guard
64
+ console.log(`📋 handoff saved for ${project}: ${file}`);
65
+ // --write-only: the in-app flow (#5509). The app ends the pane's session itself and reopens it
66
+ // through `trantor open`, which claims this handoff a Terminal window here would be exactly the
67
+ // wrong surface, so the flag writes, announces, and stops.
68
+ if (process.argv.includes("--write-only")) {
69
+ console.log(`🔄 write-only: no window spawned — the pane takeover (trantor open) claims it next.`);
70
+ process.exit(0);
71
+ }
72
+ const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
73
+ console.log(spawned
74
+ ? `🔄 baton: a fresh session is opening (it'll recap the handoff)${armed ? ` — this window (${windowId}) closes once it takes over` : " — couldn't detect this window; close it yourself once the new one is up"}`
75
+ : `handoff saved, but couldn't spawn a fresh session (non-macOS or spawn disabled) — open a new session here to take over`);
44
76
  }
45
- const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
46
- console.log(spawned
47
- ? `🔄 baton: a fresh session is opening (it'll recap the handoff)${armed ? ` — this window (${windowId}) closes once it takes over` : " — couldn't detect this window; close it yourself once the new one is up"}`
48
- : `handoff saved, but couldn't spawn a fresh session (non-macOS or spawn disabled) — open a new session here to take over`);
package/bin/connect.mjs CHANGED
@@ -40,6 +40,10 @@ function patchJson(path, mutate) {
40
40
  // while its runner sat on the pinned one — the residual split-brain mechanism (2026-08-20).
41
41
  // mcp.mjs resolves the hub from the session's project pin; that resolution must stay in charge.
42
42
  const relayEnv = (agent) => ({ RELAY_AGENT: agent });
43
+ // OpenCode hosts several differently-named seats. Its global MCP environment must not stamp all
44
+ // of them "opencode": ambient runner identity wins, while this fallback names a normal interactive
45
+ // OpenCode session that has no RELAY_AGENT/RELAY_SESSION of its own.
46
+ const hostedRelayEnv = (agent) => ({ RELAY_AGENT_FALLBACK: agent });
43
47
 
44
48
  // ---- Claude Code: plugin handles it; verify only ----
45
49
  if (has("claude")) {
@@ -87,7 +91,13 @@ if (has("opencode")) {
87
91
  report("opencode", patchJson(p, d => {
88
92
  d.$schema ||= "https://opencode.ai/config.json";
89
93
  d.mcp ||= {};
90
- d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true, environment: relayEnv("opencode") };
94
+ d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true };
95
+ d.mcp.relay.environment ||= {};
96
+ // Migrate the old generated pin too: `||=` alone left RELAY_AGENT=opencode in every existing
97
+ // config forever, where OpenCode overlaid it on the qwen/glm/deepseek runner environment.
98
+ delete d.mcp.relay.environment.RELAY_AGENT;
99
+ delete d.mcp.relay.environment.RELAY_SESSION;
100
+ Object.assign(d.mcp.relay.environment, hostedRelayEnv("opencode"));
91
101
  }), p);
92
102
  }
93
103
 
@@ -358,7 +358,7 @@ async function reportFailure(exit, trigger, undelivered = 0) {
358
358
  const reason = classify(exit);
359
359
  const down = consecFails >= 2;
360
360
  const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
361
- await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
361
+ await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
362
362
  const hint = reason === "exhausted" ? " — needs `trantor swap`"
363
363
  : reason === "auth" ? " — check credentials"
364
364
  : reason === "backend-error" ? " — provider backend error (NOT quota): retry, or `trantor swap` to another provider"
@@ -446,7 +446,7 @@ async function reportHealthy() {
446
446
  consecFails = 0;
447
447
  // Recovery is a change too, so the next failure is news again.
448
448
  announced = "";
449
- await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL }).catch(() => {});
449
+ await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
450
450
  await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
451
451
  cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
452
452
  }
@@ -512,7 +512,7 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
512
512
  } catch {}
513
513
  const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF})`], {
514
514
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
515
- env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
515
+ env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
516
516
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
517
517
  //
518
518
  // The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
@@ -673,7 +673,11 @@ function askedExcerpt(message) {
673
673
  // start cursor at the CURRENT tip so we don't replay history
674
674
  let cursor = 0;
675
675
  try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
676
- await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL }).catch(() => {});
676
+ // kind "agent" on every beat (#6075): the peer row's kind is the hub's OWN record of what a
677
+ // session is — the overseer's declared-crew exemption reads it, and on the remote hub there is
678
+ // no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
679
+ // older runner never loses a kind an updated one stamped.
680
+ await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
677
681
  // Announce runner-side, signed as THIS seat. Asking the seat to announce itself sent glm's hello
678
682
  // out under deepseek's identity whenever opencode seats shared one MCP daemon (lesson on the bus,
679
683
  // 2026-07-29): the runner process is per-seat by construction, so its signature cannot be borrowed.
@@ -726,7 +730,12 @@ function askedExcerpt(message) {
726
730
  let msgs = [];
727
731
  try {
728
732
  const r = await api(`/poll?session=${encodeURIComponent(SESSION)}&since=${cursor}&wait=${holdS}`);
729
- msgs = r.messages || []; cursor = r.cursor ?? cursor;
733
+ msgs = r.messages || [];
734
+ if (r.cursor !== undefined && r.cursor !== null && Number.isFinite(Number(r.cursor))) {
735
+ const reportedCursor = Number(r.cursor);
736
+ if (reportedCursor < cursor) log(`cursor rewound by hub ${cursor} -> ${reportedCursor}`);
737
+ cursor = reportedCursor;
738
+ }
730
739
  } catch (e) {
731
740
  // Deadline-abort on the LONG-POLL is not an outage — it means the hold expired with no hub
732
741
  // response (stalled event loop, napped machine, dead socket). Reconnect immediately and say
package/bin/new.mjs CHANGED
@@ -129,9 +129,14 @@ try {
129
129
  // way crew seats do: the operator's owner key mints a project-scoped write invite and the genesis
130
130
  // identity spends it. Only when NO owner key is configured (a loopback hub with no owner identity)
131
131
  // do we fall back to the plain TOFU enroll.
132
- const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000 });
133
- if (!viaOwner.ok && viaOwner.reason === "no-owner-key") await enrollTofu(session, identity, name);
132
+ const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000, kind: "tool" });
133
+ if (!viaOwner.ok && viaOwner.reason === "no-owner-key") await enrollTofu(session, identity, name, { kind: "tool" });
134
134
  else if (!viaOwner.ok) console.error(`genesis: enrollment via owner invite failed: ${viaOwner.reason}`);
135
+ // #6068: say WHAT this session is. The genesis identity exists to post the brief — without a
136
+ // kind on its peer row the app's seat strip renders it as a seat ("no terminal pane — start it
137
+ // with trantor up genesis"). /register is presence, not speech: no message rides it, nobody
138
+ // wakes. The hub stamps kind on the session row and /peers returns it to the app.
139
+ await signedPost("/register", { session, kind: "tool" }, { session, project: name, timeoutMs: 8000 }).catch(() => {});
135
140
  const briefForHub = (brief || `Genesis of ${name} — created by trantor new.`).slice(0, 600);
136
141
  const r1 = await signedPost("/project", { project: name, brief: briefForHub, by: session }, { session, project: name, timeoutMs: 8000 });
137
142
  if (!r1.ok) throw new Error(`hub ${r1.status} on /project${r1.json?.error ? `: ${r1.json.error}` : ""}`);
@@ -6,7 +6,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from
6
6
  import { join, basename } from "node:path";
7
7
  import { homedir, hostname } from "node:os";
8
8
  import { execSync } from "node:child_process";
9
- import { spawnBaton, handoffMode } from "../hooks/lib/handoff.mjs";
9
+ import { spawnBaton, handoffMode, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
10
10
  import { handoffDir } from "../lib/project.mjs";
11
11
 
12
12
  const baton = process.argv.includes("--baton");
@@ -14,8 +14,14 @@ const baton = process.argv.includes("--baton");
14
14
  // fresh one on stdin", so a session that had just written a 5KB handoff had to write it again to
15
15
  // hand it over — 2m28s of regenerated prose on a live scribe session, 2026-08-24.
16
16
  const latest = process.argv.includes("--latest");
17
- const project = process.env.CLAUDE_PROJECT_DIR || process.cwd();
18
- const name = basename(project);
17
+ // #6074: ONE resolver for which project this is and where the session lives — shared with
18
+ // bin/baton.mjs (the `trantor handoff` CLI path) so the two cannot diverge. The name comes from
19
+ // the session's registration (TRANTOR_ORCH / RELAY_PROJECT / orch-sessions.txt) before the cwd;
20
+ // the witnessed crebral-scribe/ios handoff recorded project "ios" from a subfolder cwd and never
21
+ // found its pane.
22
+ const resolved = resolveHandoffSurface({ sessionId: process.env.CLAUDE_SESSION_ID || "" });
23
+ const project = resolved.projectDir;
24
+ const name = resolved.project;
19
25
  let summary = "";
20
26
  if (!latest) {
21
27
  process.stdin.setEncoding("utf8");
@@ -0,0 +1,50 @@
1
+ #!/bin/bash
2
+ # Guarded production restart: an unhealthy durable writer means RAM contains the only current copy.
3
+ set -euo pipefail
4
+
5
+ FORCE=0
6
+ if [ "${1:-}" = "--force" ]; then
7
+ FORCE=1
8
+ shift
9
+ fi
10
+ if [ "$#" -ne 0 ]; then
11
+ echo "usage: deploy/restart-hub.sh [--force]" >&2
12
+ exit 2
13
+ fi
14
+
15
+ # The hub binds RELAY_HOST (the tailnet IP on netcup, where 127.0.0.1 refuses), so the guard reads the
16
+ # same env the service does; RELAY_HUB_URL still overrides.
17
+ ENV_FILE="${RELAY_ENV_FILE:-$(dirname "$0")/hub.env}"
18
+ if [ -f "$ENV_FILE" ]; then
19
+ # shellcheck disable=SC1090
20
+ set -a; . "$ENV_FILE"; set +a
21
+ fi
22
+ HUB_URL="${RELAY_HUB_URL:-http://${RELAY_HOST:-127.0.0.1}:${RELAY_PORT:-4477}}"
23
+ HEALTH=""
24
+ if HEALTH="$(curl --fail --silent --show-error --max-time 5 "$HUB_URL/health")"; then
25
+ REFUSAL="$(node --input-type=module -e '
26
+ const chunks = [];
27
+ for await (const chunk of process.stdin) chunks.push(chunk);
28
+ let health;
29
+ try { health = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
30
+ catch { process.stdout.write("hub health response was not valid JSON"); process.exit(0); }
31
+ const p = health.persist;
32
+ if (!p || p.ok !== false) process.exit(0);
33
+ const age = Number(p.failingSinceMs || 0);
34
+ const span = age >= 60000 ? `${Math.floor(age / 60000)}m ${Math.floor((age % 60000) / 1000)}s` : `${Math.floor(age / 1000)}s`;
35
+ process.stdout.write(`hub persistence has failed for ${span} (${Number(p.retries || 0)} retries): ${String(p.lastError || "unknown error")}. Restarting risks losing every state change since persistence stopped.`);
36
+ ' <<<"$HEALTH")"
37
+ if [ -n "$REFUSAL" ] && [ "$FORCE" -ne 1 ]; then
38
+ echo "REFUSED: $REFUSAL" >&2
39
+ echo "Resolve persistence first, or rerun with --force to accept the data-loss risk." >&2
40
+ exit 1
41
+ fi
42
+ if [ -n "$REFUSAL" ]; then
43
+ echo "WARNING: --force accepted: $REFUSAL" >&2
44
+ fi
45
+ else
46
+ echo "WARNING: $HUB_URL/health was unreachable; proceeding because no running hub state can be inspected." >&2
47
+ fi
48
+
49
+ systemctl restart trantor-hub
50
+ echo "restarted trantor-hub"
@@ -72,10 +72,15 @@ echo " schema applied"
72
72
  # systemd unit
73
73
  cp deploy/trantor-hub.service /etc/systemd/system/trantor-hub.service
74
74
  systemctl daemon-reload
75
- systemctl enable --now trantor-hub
75
+ systemctl enable trantor-hub
76
+ if systemctl is-active --quiet trantor-hub; then
77
+ bash deploy/restart-hub.sh
78
+ else
79
+ systemctl start trantor-hub
80
+ fi
76
81
 
77
82
  # crons: daily backup + nightly retention
78
- chmod +x deploy/backup.sh deploy/retention.sh deploy/restore.sh
83
+ chmod +x deploy/backup.sh deploy/retention.sh deploy/restore.sh deploy/restart-hub.sh
79
84
  echo "0 2 * * * root /opt/trantor/deploy/backup.sh" > /etc/cron.d/trantor
80
85
  echo "0 3 * * * root /opt/trantor/deploy/retention.sh" >> /etc/cron.d/trantor
81
86
 
package/hooks/lib/api.mjs CHANGED
@@ -112,7 +112,7 @@ function enrolledPath(session) {
112
112
  const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
113
113
  return join(busDir, "keys", `${String(session).replace(/[^A-Za-z0-9_.-]/g, "_")}.enrolled`);
114
114
  }
115
- export async function ensureEnrolled(session, identity, project) {
115
+ export async function ensureEnrolled(session, identity, project, { kind = "agent" } = {}) {
116
116
  if (!identity?.pubkey) return;
117
117
  const hub = relayUrl(project);
118
118
  const stamp = enrolledPath(session);
@@ -126,7 +126,7 @@ export async function ensureEnrolled(session, identity, project) {
126
126
  // payload, sets content-type, and signs — so every hook signs identically with zero hand-rolling.
127
127
  const r = await sfetchJson(`${hub}/enroll`, {
128
128
  method: "POST",
129
- payload: { pubkey: identity.pubkey, name: session, kind: "agent" },
129
+ payload: { pubkey: identity.pubkey, name: session, kind },
130
130
  identity,
131
131
  signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
132
132
  });
@@ -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}` };
@@ -0,0 +1,56 @@
1
+ // Persistence is a standing health condition, not a stream of retry errors. This tracker keeps the
2
+ // retry schedule and the operator-facing state together so they cannot disagree.
3
+ export const PERSIST_RETRY_BASE_MS = 1000;
4
+ export const PERSIST_RETRY_MAX_MS = 60_000;
5
+ export const PERSIST_LOG_INTERVAL_MS = 60_000;
6
+
7
+ const positive = (value, fallback) => {
8
+ const n = Number(value);
9
+ return Number.isFinite(n) && n > 0 ? n : fallback;
10
+ };
11
+
12
+ export function createPersistHealth(options = {}) {
13
+ const baseMs = positive(options.baseMs, PERSIST_RETRY_BASE_MS);
14
+ const maxMs = Math.max(baseMs, positive(options.maxMs, PERSIST_RETRY_MAX_MS));
15
+ const logIntervalMs = positive(options.logIntervalMs, PERSIST_LOG_INTERVAL_MS);
16
+ let failedAt = 0;
17
+ let lastError = "";
18
+ let retries = 0;
19
+ let retryMs = baseMs;
20
+ let nextAttemptAt = 0;
21
+ let lastLogAt = -Infinity;
22
+
23
+ const view = (at = Date.now()) => ({
24
+ ok: retries === 0,
25
+ failingSinceMs: retries === 0 ? 0 : Math.max(0, Number(at) - failedAt),
26
+ lastError,
27
+ retries,
28
+ });
29
+
30
+ return {
31
+ canAttempt(at = Date.now()) {
32
+ return retries === 0 || Number(at) >= nextAttemptAt;
33
+ },
34
+ failed(error, at = Date.now()) {
35
+ const when = Number(at);
36
+ if (retries === 0) failedAt = when;
37
+ retries += 1;
38
+ lastError = String(error?.message || error || "persist failed").replace(/\u0000/g, "").replace(/\s+/g, " ").slice(0, 500);
39
+ const delayMs = retryMs;
40
+ nextAttemptAt = when + delayMs;
41
+ retryMs = Math.min(maxMs, retryMs * 2);
42
+ const shouldLog = when - lastLogAt >= logIntervalMs;
43
+ if (shouldLog) lastLogAt = when;
44
+ return { delayMs, shouldLog, health: view(when) };
45
+ },
46
+ succeeded() {
47
+ failedAt = 0;
48
+ lastError = "";
49
+ retries = 0;
50
+ retryMs = baseMs;
51
+ nextAttemptAt = 0;
52
+ lastLogAt = -Infinity;
53
+ },
54
+ view,
55
+ };
56
+ }
@@ -0,0 +1,65 @@
1
+ // #5760 — the same-project OVERSEER warning is an EPISODE, not a timer. The night of 08-31 the
2
+ // "🤝 OVERSEER same-project-sessions" DM re-fired hourly and woke every seat into metered chatter
3
+ // turns for a membership that never changed. The monitoring doctrine this project holds everyone
4
+ // else to — report duration, not repetition — applied to its own emitter:
5
+ //
6
+ // 1. The warning fires ONCE when the membership set CHANGES — a session joins or leaves —
7
+ // keyed by a stable hash of the member set. Never on a clock.
8
+ // 2. An unchanged set re-warns never. When a change does fire, the record line reports
9
+ // DURATION ("same-project for 6h"), not repetition.
10
+ // 3. An operator-declared crew (the seats `trantor up` spawned plus the orchestrator) is the
11
+ // NORMAL state of a project, not a collision: only sessions OUTSIDE the declared crew
12
+ // trigger the warning at all.
13
+ //
14
+ // Pure module: no I/O, no imports. The hub owns persistence (the previously-fired set and when)
15
+ // and the crew declaration; this file only decides.
16
+ const clean = (v) => String(v ?? "").trim();
17
+
18
+ // The member SET: sorted, deduped — so set comparison and hashing never see order or repeats.
19
+ export function memberSet(sessions) {
20
+ return [...new Set((sessions ?? []).map(clean).filter(Boolean))].sort((a, b) => a.localeCompare(b));
21
+ }
22
+
23
+ // A stable hash of the member set, so "did the membership change" is one comparison that
24
+ // survives process restarts and set reordering. FNV-1a over the joined members: deterministic
25
+ // everywhere, no dependencies.
26
+ export function memberSetHash(sessions) {
27
+ let h = 0x811c9dc5;
28
+ for (const ch of memberSet(sessions).join("\u0000")) {
29
+ h ^= ch.codePointAt(0);
30
+ h = Math.imul(h, 0x01000193) >>> 0;
31
+ }
32
+ return h.toString(16);
33
+ }
34
+
35
+ // "6h" / "12m" / "<1m" — the record line states how long the episode has held, never a count
36
+ // of warnings. An unchanged state is not news; its DURATION is.
37
+ export function durationLabel(ms) {
38
+ const m = Math.floor(Math.max(0, Number(ms) || 0) / 60000);
39
+ if (m < 1) return "<1m";
40
+ if (m < 60) return `${m}m`;
41
+ return `${Math.floor(m / 60)}h`;
42
+ }
43
+
44
+ // Fire-or-not. previous = the member set as of the LAST fire (null = never fired); current = the
45
+ // live sessions on the project now; declaredCrew = the operator's crew (seats + orchestrator);
46
+ // lastFiredAt = when the last fire happened (null = never). The clock inputs exist ONLY to state
47
+ // duration — there is no elapsed-time threshold anywhere: a change fires, a hold never does.
48
+ export function sameProjectDecision({ previous = null, current = [], declaredCrew = [], lastFiredAt = null, now = 0 } = {}) {
49
+ const cur = memberSet(current);
50
+ const crew = new Set(memberSet(declaredCrew));
51
+ const intruders = cur.filter((s) => !crew.has(s));
52
+ if (intruders.length === 0) return { fire: false, reason: "crew-only", intruders: [], durationMs: 0 };
53
+ if (previous == null || lastFiredAt == null) {
54
+ return { fire: true, reason: "first-sighting", intruders, durationMs: 0 };
55
+ }
56
+ if (memberSetHash(previous) === memberSetHash(cur)) {
57
+ return { fire: false, reason: "unchanged", intruders, durationMs: 0 };
58
+ }
59
+ return {
60
+ fire: true,
61
+ reason: "membership-changed",
62
+ intruders,
63
+ durationMs: Math.max(0, (Number(now) || 0) - (Number(lastFiredAt) || 0)),
64
+ };
65
+ }
package/lib/store-pg.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  // trantor Phase 1 Postgres store.
2
2
  // Implements the frozen STORE_API from ./store-contract.mjs. The hub still keeps its existing
3
3
  // in-memory projection while running; this store is the durable backing when RELAY_STORE=pg.
4
+ // SAFETY: This module is the Postgres I/O decoder/encoder boundary. Its runtime `typeof` checks
5
+ // validate untrusted jsonb rows and caller-supplied JSON before those values enter the hub domain.
6
+ /* oxlint-disable anti-slop/no-runtime-typeof */
4
7
  import { SCHEMA_SQL, SCHEMA_VERSION, KV_KEYS, DEFAULT_ORG, CHANGE_CHANNEL } from "./store-contract.mjs";
5
8
 
6
9
  const asJson = (v, fallback) => (v === undefined ? fallback : v);
@@ -10,9 +13,21 @@ const ms = (v, fallback = Date.now()) => {
10
13
  };
11
14
  const num = (v) => (v == null ? v : Number(v));
12
15
 
16
+ // PostgreSQL rejects U+0000 in both text and jsonb. Sanitize the complete row projection here,
17
+ // below every caller, so an old client or an imported nested payload cannot poison the delta.
18
+ export function stripNulDeep(value) {
19
+ if (typeof value === "string") return value.replace(/\u0000/g, "");
20
+ if (Array.isArray(value)) return value.map(stripNulDeep);
21
+ if (value && typeof value === "object") {
22
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key.replace(/\u0000/g, ""), stripNulDeep(child)]));
23
+ }
24
+ return value;
25
+ }
26
+
13
27
  const EVENT_COLUMN_KEYS = ["id", "ts", "type", "project", "by", "by_session", "taskId", "task_id", "payload"];
14
28
  function stripEventPayload(evt = {}) {
15
- const payload = { ...(evt.payload && typeof evt.payload === "object" ? evt.payload : {}) };
29
+ const payload = {};
30
+ if (evt.payload && typeof evt.payload === "object") Object.assign(payload, evt.payload);
16
31
  // The nested payload gets the same treatment as the top level. It used to be copied in wholesale,
17
32
  // so a column key riding inside it (an `id` from an imported event) was stored and then clobbered
18
33
  // the real column on the way back out.
@@ -512,7 +527,10 @@ export class PgStore {
512
527
  // Emits NOTIFY on CHANGE_CHANNEL inside the transaction (delivered on commit) so other hubs
513
528
  // reload; `src` lets a hub ignore its own notifications.
514
529
  async saveDelta(orgId, prev, next, { src = "" } = {}) {
515
- prev = prev || {};
530
+ orgId = String(stripNulDeep(orgId));
531
+ src = String(stripNulDeep(src));
532
+ prev = stripNulDeep(prev || {});
533
+ next = stripNulDeep(next || {});
516
534
  const tasks = diffById(prev.tasks, next.tasks, t => Number(t.id));
517
535
  const events = diffById(prev.events, next.events, e => Number(e.id));
518
536
  const messages = diffById(prev.messages, next.messages, m => Number(m.id));
package/mcp.mjs CHANGED
@@ -68,10 +68,12 @@ const PROJECT = resolveProject(process.env.CLAUDE_PROJECT_DIR || process.cwd());
68
68
  // global `url` → local default. A project lives on exactly one hub; codependent projects
69
69
  // must share one, so both are pinned to the same hub via `trantor hub set`.
70
70
  const URL_BASE = resolveHub(PROJECT); // boot-time snapshot: startup log only — every api() call re-resolves
71
- // Identity: RELAY_SESSION wins; else RELAY_AGENT ("codex", "kimi", …) brands the session per-project
72
- // (set it once in the CLI's global MCP config works in every project); else hostname:project.
71
+ // Identity: the runner's exact RELAY_SESSION wins, then its RELAY_AGENT. A multi-seat host such as
72
+ // OpenCode contributes only RELAY_AGENT_FALLBACK in its global MCP config, so qwen/glm/deepseek do
73
+ // not get rebranded "opencode" when that config is overlaid on the runner environment.
74
+ const SESSION_AGENT = process.env.RELAY_AGENT || process.env.RELAY_AGENT_FALLBACK;
73
75
  const SESSION = process.env.RELAY_SESSION
74
- || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${PROJECT}` : `${hostId()}:${PROJECT}`);
76
+ || (SESSION_AGENT ? `${SESSION_AGENT}:${PROJECT}` : `${hostId()}:${PROJECT}`);
75
77
  let cursor = 0;
76
78
  // First-call guard: a brand-new MCP process must NOT replay the entire historical backlog (observed:
77
79
  // 2,379 msgs / 520KB back to an old asteroids project) the instant relay_inbox/relay_wait is called.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.32",
3
+ "version": "0.18.33",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,7 +11,7 @@
11
11
  "zod": "^4.4.3"
12
12
  },
13
13
  "scripts": {
14
- "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.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-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && node test-crew-redact.mjs && node test-crew-classify.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-provider-cli.mjs && node test-crew-model-defaults.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-new.mjs && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
14
+ "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-cursor-rewind.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-persist-safety.mjs && node test-message-re.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-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && node test-crew-redact.mjs && node test-crew-classify.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-provider-cli.mjs && node test-crew-model-defaults.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-new.mjs && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.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": [
@@ -20,8 +20,19 @@ only to plain Terminal sessions.
20
20
 
21
21
  ## Instructions
22
22
 
23
+ PREFER THE GLOBAL `trantor` BINARY when it is on PATH (`command -v trantor`): it runs the
24
+ INSTALLED version's code, while `${CLAUDE_PLUGIN_ROOT}` is the plugin-cache copy pinned when this
25
+ session booted — a session opened before an update runs stale logic (witnessed 2026-09-02: a
26
+ 0.18.20 cache copy mis-resolved the project from a subfolder cwd and closed a Terminal window that
27
+ was not its own). Use `trantor handoff …` for both commands below; fall back to the
28
+ `node "${CLAUDE_PLUGIN_ROOT}/bin/write-handoff.mjs" …` form only when `trantor` is absent.
29
+ `trantor handoff` takes piped markdown (a heredoc) and `--latest` exactly like the helper, because
30
+ it forwards to the same package's `write-handoff.mjs`.
31
+
23
32
  0. **Already written one this session?** Then do NOT write it again — pass the baton on it:
24
33
  ```bash
34
+ trantor handoff --latest
35
+ # or, without the global binary:
25
36
  node "${CLAUDE_PLUGIN_ROOT}/bin/write-handoff.mjs" --baton --latest
26
37
  ```
27
38
  That picks this project's newest unconsumed handoff and hands it over untouched, and exits
@@ -39,10 +50,12 @@ only to plain Terminal sessions.
39
50
 
40
51
  2. Save it AND pass the baton in one shot — pipe the markdown to the helper with `--baton`:
41
52
  ```bash
42
- cat << 'HANDOFF' | node "${CLAUDE_PLUGIN_ROOT}/bin/write-handoff.mjs" --baton
53
+ trantor handoff --baton << 'HANDOFF'
43
54
  <your handoff markdown>
44
55
  HANDOFF
45
56
  ```
57
+ (or `cat << 'HANDOFF' | node "${CLAUDE_PLUGIN_ROOT}/bin/write-handoff.mjs" --baton` without the
58
+ global binary.)
46
59
  `--baton` writes the handoff, opens a FRESH session that takes over (it auto-recaps the handoff
47
60
  on open), and closes THIS Terminal window once the fresh session has consumed it — a true baton
48
61
  pass, one session at a time. (Omit `--baton` to only write the handoff without spawning/closing.)