trantor 0.17.13 → 0.17.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + context-handoff for independent AI coding agents (Claude, Codex, Gemini, …)",
9
- "version": "0.17.13"
9
+ "version": "0.17.14"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "trantor",
14
14
  "source": "./",
15
15
  "description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, Gemini, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
16
- "version": "0.17.13",
16
+ "version": "0.17.14",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.13",
3
+ "version": "0.17.14",
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": {
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // trantor catchup — "where are we on this project?" on demand. Reads the continuous
3
+ // board (the durable, cross-session project record), the recent git log, and — if
4
+ // scrooge is on PATH — synthesizes a short narrative. The SessionStart hook already
5
+ // injects the structured snapshot every start; this is the richer brief you ask for
6
+ // when you want it. Run from inside a project: `trantor catchup`.
7
+ import { readFileSync, existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { execSync } from "node:child_process";
11
+ import { resolveProject } from "../lib/project.mjs";
12
+
13
+ function relayUrl() {
14
+ if (process.env.RELAY_URL) return process.env.RELAY_URL;
15
+ try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
16
+ return "http://127.0.0.1:4477";
17
+ }
18
+ const haveScrooge = () => { try { execSync("command -v scrooge", { stdio: "ignore" }); return true; } catch { return false; } };
19
+
20
+ const dir = process.cwd();
21
+ const project = resolveProject(dir);
22
+ const url = relayUrl();
23
+
24
+ let cu = null;
25
+ try { cu = await (await fetch(`${url}/catchup?project=${encodeURIComponent(project)}`, { signal: AbortSignal.timeout(4000) })).json(); } catch (e) { console.error(`could not reach hub at ${url}: ${e.message}`); }
26
+ let gitlog = "";
27
+ try { gitlog = execSync(`git -C ${JSON.stringify(dir)} log --oneline -12 2>/dev/null`, { encoding: "utf8" }).trim(); } catch {}
28
+
29
+ const line = (arr) => (arr || []).map(t => ` #${t.id} ${String(t.title).slice(0, 80)}${t.assignee ? ` @${t.assignee}` : ""}`).join("\n");
30
+ console.log(`\n📋 trantor catchup — ${project}\n${"─".repeat(48)}`);
31
+ if (cu && cu.total > 0) {
32
+ if (cu.brief) console.log(`Brief: ${cu.brief}\n`);
33
+ const c = cu.counts;
34
+ console.log(`Cards: ${cu.total} — ${c.done} done · ${c.doing} doing · ${c.testing} testing · ${c.todo} todo · ${c.failed} failed · ${c.blocked} blocked`);
35
+ if (cu.doing?.length) console.log(`\nIn progress:\n${line(cu.doing)}`);
36
+ if (cu.testing?.length) console.log(`\nIn testing:\n${line(cu.testing)}`);
37
+ if (cu.failed?.length) console.log(`\nFailed (needs attention):\n${line(cu.failed)}`);
38
+ if (cu.blocked?.length) console.log(`\nBlocked:\n${line(cu.blocked)}`);
39
+ if (cu.todo?.length) console.log(`\nQueued:\n${line(cu.todo)}`);
40
+ if (cu.recentDone?.length) console.log(`\nRecently done:\n${line(cu.recentDone)}`);
41
+ } else {
42
+ console.log(`No cards on the board for "${project}" yet.`);
43
+ }
44
+ if (gitlog) console.log(`\nRecent commits:\n${gitlog.split("\n").map(l => " " + l).join("\n")}`);
45
+
46
+ if (haveScrooge() && (cu?.total || gitlog)) {
47
+ const ctx = `PROJECT: ${project}\n\nBOARD:\n${JSON.stringify(cu, null, 2)}\n\nRECENT COMMITS:\n${gitlog}`;
48
+ const sys = "You are briefing someone resuming this project. From the board state + git log, write a SHORT 'where are we' narrative: what's built, what's in flight, what's next, and any risk (failed/blocked cards, or a stale card whose work looks already done elsewhere). 6-10 lines. No preamble.";
49
+ try {
50
+ console.log(`\n${"─".repeat(48)}\nWhere we are:\n`);
51
+ console.log(execSync(`scrooge -t summarize -d medium --system ${JSON.stringify(sys)}`, { input: ctx, encoding: "utf8", timeout: 60000, maxBuffer: 4 * 1024 * 1024 }).trim());
52
+ } catch (e) { console.error(`(scrooge brief skipped: ${e.message})`); }
53
+ } else if (!haveScrooge()) {
54
+ console.log(`\n(install scrooge for a synthesized narrative)`);
55
+ }
56
+ console.log("");
package/bin/cli.mjs CHANGED
@@ -25,6 +25,7 @@ switch (cmd) {
25
25
  case "swap": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "swap", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
26
26
  case "hub": run("hub.mjs"); break;
27
27
  case "watch": run("bin/relay-watch.mjs"); break;
28
+ case "catchup": run("bin/catchup.mjs"); break;
28
29
  case "ui": {
29
30
  let url = "http://127.0.0.1:4477";
30
31
  try { url = JSON.parse(readFileSync(join(process.env.HOME || "", ".agent-bus", "config.json"), "utf8")).url || url; } catch {}
@@ -44,6 +45,7 @@ switch (cmd) {
44
45
  trantor up … spawn a crew here: trantor up codex gemini kimi deepseek:deepseek-v4-pro
45
46
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
46
47
  trantor ui open the live dashboard (board + flow views)
48
+ trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
47
49
  trantor advise ask the Advisor directly (JSON on stdin; --demo to see it)
48
50
  trantor hub run the hub in the foreground (setup installs it as a service instead)
49
51
  trantor watch live bus feed in the terminal
@@ -12,10 +12,15 @@ import { execSync, spawnSync } from "node:child_process";
12
12
  import { readFileSync, existsSync, appendFileSync } from "node:fs";
13
13
  import { join, basename } from "node:path";
14
14
  import { homedir } from "node:os";
15
+ import { resolveProject } from "../lib/project.mjs";
15
16
 
16
17
  const AGENT = process.argv[2];
17
18
  const DIR = process.argv[3] || process.cwd();
18
- const PROJ = basename(DIR);
19
+ // Crew agents MUST share the orchestrator's project key (one repo = one lane).
20
+ // RELAY_PROJECT is inherited from crew.sh (the host's resolved key); else fall
21
+ // back to the git-repo-root basename — never a loose dir basename that could
22
+ // fork the host's "builtbetter.ai" into a separate "builtbetter" lane.
23
+ const PROJ = process.env.RELAY_PROJECT || resolveProject(DIR);
19
24
  const SESSION = `${AGENT}:${PROJ}`;
20
25
  if (!AGENT) { console.error("usage: crew-runner.mjs <agent> [project-dir]"); process.exit(1); }
21
26
 
package/bin/crew.sh CHANGED
@@ -16,7 +16,10 @@
16
16
  set -u
17
17
  CMD="${1:-up}"; shift 2>/dev/null || true
18
18
  DIR="$(pwd)"
19
- PROJ="$(basename "$DIR")"
19
+ # Canonical project key: the orchestrator's RELAY_PROJECT wins, else the GIT REPO ROOT
20
+ # basename (stable across subdirs), else the cwd basename. The crew inherits this exact
21
+ # key so one repo = one lane (no host "builtbetter.ai" vs crew "builtbetter" split).
22
+ PROJ="${RELAY_PROJECT:-$(basename "$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null || echo "$DIR")")}"
20
23
  BUS_DIR="$(cd "$(dirname "$0")/.." && pwd)"
21
24
  STATE="$HOME/.agent-bus/crew-windows.txt"
22
25
  mkdir -p "$HOME/.agent-bus"
@@ -143,7 +146,7 @@ spawn_grid() { # $@ = agents — (re)computes the grid for THIS batch and spawn
143
146
  local X1=$(( GX + C * CW )) Y1=$(( GY + R * CH )) WID=""
144
147
  WID="$(osascript \
145
148
  -e 'tell application "Terminal"' \
146
- -e " set w to do script \"cd $DIR && clear && CREW_MODEL=$MODEL node $BUS_DIR/bin/crew-runner.mjs $AGENT $DIR\"" \
149
+ -e " set w to do script \"cd $DIR && clear && CREW_MODEL=$MODEL RELAY_PROJECT=$PROJ node $BUS_DIR/bin/crew-runner.mjs $AGENT $DIR\"" \
147
150
  -e " set custom title of w to \"$(echo "$AGENT" | tr '[:lower:]' '[:upper:]') — trantor crew\"" \
148
151
  -e " set theWin to first window whose tabs contains w" \
149
152
  -e " set bounds of theWin to {$X1, $Y1, $(( X1 + CW )), $(( Y1 + CH ))}" \
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // trantor — detached handoff worker. The PostToolUse heartbeat spawns this when a
3
+ // session crosses its context warn threshold, so the (up to ~60s) scrooge summary
4
+ // never blocks a tool call. Writes a whole-session handoff and prompts to open a
5
+ // fresh session. Args: <projectDir> <sessionId> <transcriptPath> [trigger]
6
+ import { readConfig, writeHandoff, pingBus, maybeSpawn,
7
+ contextUsage, alreadyHandedOff, markHandedOff } from "./lib/handoff.mjs";
8
+ import { basename } from "node:path";
9
+
10
+ const [, , projectDir = process.cwd(), sessionId = "", transcript = "", trigger = "context-warn"] = process.argv;
11
+ try {
12
+ const conf = readConfig();
13
+ const cur = contextUsage(transcript, conf)?.tokens || 0;
14
+ if (alreadyHandedOff(sessionId, cur)) process.exit(0); // another path beat us to it
15
+ const { file, record } = writeHandoff({ projectDir, sessionId, transcript, trigger });
16
+ process.stderr.write(`[trantor] early handoff written: ${file} (frac warn)\n`);
17
+ await pingBus(basename(projectDir), record.id, conf);
18
+ if (maybeSpawn(projectDir, conf)) markHandedOff(sessionId, cur);
19
+ } catch (e) {
20
+ process.stderr.write(`[trantor] handoff-now error: ${e?.message || e}\n`);
21
+ }
22
+ process.exit(0);
@@ -14,11 +14,54 @@
14
14
  // We POST /register WITHOUT a status field so the session's meaningful status is preserved
15
15
  // (the hub only overwrites status when one is supplied).
16
16
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
17
- import { join, basename } from "node:path";
17
+ import { join, basename, dirname } from "node:path";
18
18
  import { homedir, hostname } from "node:os";
19
+ import { spawn } from "node:child_process";
20
+ import { fileURLToPath } from "node:url";
21
+ import { readConfig, contextUsage, warnFrac, alreadyHandedOff } from "./lib/handoff.mjs";
22
+ import { resolveProject } from "../lib/project.mjs";
19
23
 
20
24
  const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
21
25
  const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
26
+ const INFLIGHT_MS = 5 * 60 * 1000;
27
+ const HERE = dirname(fileURLToPath(import.meta.url));
28
+
29
+ function readStdin() {
30
+ return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
31
+ process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
32
+ setTimeout(() => res(d), 80); });
33
+ }
34
+
35
+ // Proactive early-warning: when the live context occupancy crosses the warn
36
+ // fraction of a KNOWN window (env RELAY_CONTEXT_WINDOW / config.contextWindow —
37
+ // the transcript can't reveal 200k vs 1M, so it must be declared), hand off
38
+ // BEFORE the compaction wall. The heavy summary runs in a detached worker so we
39
+ // never block this tool call. No-op when the window is unknown.
40
+ async function maybeEarlyWarn(stdinRaw, session) {
41
+ try {
42
+ const conf = readConfig();
43
+ const input = JSON.parse(stdinRaw || "{}");
44
+ const transcript = input.transcript_path || "";
45
+ const sessionId = input.session_id || "";
46
+ if (!transcript) return;
47
+ const usage = contextUsage(transcript, conf);
48
+ if (!usage || !usage.window || usage.frac == null) return; // window unknown → only PreCompact guards
49
+ if (usage.frac < warnFrac(conf)) return;
50
+ if (alreadyHandedOff(sessionId, usage.tokens)) return;
51
+
52
+ // In-flight guard: the detached worker takes ~tens of seconds to summarize;
53
+ // don't launch a second one on the next heartbeat tick meanwhile.
54
+ const inflight = join(homedir(), ".agent-bus", `handoff-inflight-${String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "_")}.stamp`);
55
+ try { if (existsSync(inflight) && Date.now() - (Number(readFileSync(inflight, "utf8")) || 0) < INFLIGHT_MS) return; } catch {}
56
+ try { writeFileSync(inflight, String(Date.now())); } catch {}
57
+
58
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
59
+ process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% of ${usage.window} — launching early handoff\n`);
60
+ const child = spawn(process.execPath, [join(HERE, "handoff-now.mjs"), projectDir, sessionId, transcript, "context-warn"],
61
+ { detached: true, stdio: "ignore" });
62
+ child.unref();
63
+ } catch {}
64
+ }
22
65
 
23
66
  function relayUrl() {
24
67
  if (process.env.RELAY_URL) return process.env.RELAY_URL;
@@ -29,7 +72,7 @@ function relayUrl() {
29
72
  return "http://127.0.0.1:4477";
30
73
  }
31
74
 
32
- async function main() {
75
+ async function main(stdinRaw) {
33
76
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
34
77
  // Mirror sessionstart.mjs: home-directory sessions aren't project work — don't register
35
78
  // them (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
@@ -38,11 +81,11 @@ async function main() {
38
81
  // Mirror mcp.mjs identity resolution EXACTLY so we refresh the same peer the relay
39
82
  // registered (not a phantom): RELAY_PROJECT wins for project; RELAY_SESSION wins for
40
83
  // identity, else a RELAY_AGENT brand ("codex","kimi",…) per project, else hostname:project.
41
- const project = process.env.RELAY_PROJECT || basename(projectDir);
84
+ const project = resolveProject(projectDir);
42
85
  const session = process.env.RELAY_SESSION
43
86
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
44
87
 
45
- // Throttle: only ping if HEARTBEAT_MS has elapsed since the last ping for THIS session.
88
+ // Throttle: only act if HEARTBEAT_MS has elapsed since the last tick for THIS session.
46
89
  const stamp = join(homedir(), ".agent-bus", `hb-${session.replace(/[^A-Za-z0-9_.-]/g, "_")}.stamp`);
47
90
  try {
48
91
  if (existsSync(stamp)) {
@@ -62,7 +105,11 @@ async function main() {
62
105
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
63
106
  });
64
107
  } catch {}
108
+
109
+ // Same cadence as the presence ping: check context pressure and hand off early
110
+ // if we've crossed the warn threshold of a known window.
111
+ await maybeEarlyWarn(stdinRaw, session);
65
112
  }
66
113
 
67
114
  // Never block or break the tool flow: swallow everything, always exit clean.
68
- main().catch(() => {}).finally(() => process.exit(0));
115
+ readStdin().then(main).catch(() => {}).finally(() => process.exit(0));
@@ -0,0 +1,225 @@
1
+ // trantor handoff core — shared by the PreCompact hook (at-the-wall) and the
2
+ // PostToolUse heartbeat (proactive early-warning). One place that knows how to:
3
+ // • read a session's live context occupancy from its transcript usage,
4
+ // • build a WHOLE-SESSION summary (not just the tail),
5
+ // • write a handoff record, and
6
+ // • spawn a fresh same-agent session in a new terminal that takes it over.
7
+ //
8
+ // Why this exists: PreCompact fires only at the compaction wall and cannot stop
9
+ // compaction, so the only way to continue with a full window is to open a NEW
10
+ // session that loads the handoff. The heartbeat path lets us do that BEFORE the
11
+ // wall when we know the window size. Both paths share a per-session guard so we
12
+ // never write/spawn twice for the same context window.
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, readSync, fstatSync, closeSync } from "node:fs";
14
+ import { join, basename, dirname } from "node:path";
15
+ import { homedir, hostname } from "node:os";
16
+ import { execSync, spawn } from "node:child_process";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ export const HANDOFF_DIR = join(homedir(), ".agent-bus", "handoffs");
20
+ const HERE = dirname(fileURLToPath(import.meta.url));
21
+
22
+ export function readConfig() {
23
+ try {
24
+ const cfg = join(homedir(), ".agent-bus", "config.json");
25
+ return existsSync(cfg) ? JSON.parse(readFileSync(cfg, "utf8")) : {};
26
+ } catch { return {}; }
27
+ }
28
+
29
+ export function relayUrl(conf = readConfig()) {
30
+ return process.env.RELAY_URL || conf.url || "http://127.0.0.1:4477";
31
+ }
32
+
33
+ // ---- context occupancy ------------------------------------------------------
34
+ // Read only the tail of the (potentially huge, append-only) transcript and find
35
+ // the most recent assistant turn's usage. Current context tokens ≈ input +
36
+ // cache_read + cache_creation (the cached prompt IS part of the window).
37
+ export function contextUsage(transcriptPath, conf = readConfig()) {
38
+ if (!transcriptPath || !existsSync(transcriptPath)) return null;
39
+ let buf = "";
40
+ try {
41
+ const fd = openSync(transcriptPath, "r");
42
+ try {
43
+ const size = fstatSync(fd).size;
44
+ const tail = Math.min(size, 1_500_000); // last ~1.5MB is plenty for recent turns
45
+ const b = Buffer.alloc(tail);
46
+ readSync(fd, b, 0, tail, size - tail);
47
+ buf = b.toString("utf8");
48
+ } finally { closeSync(fd); }
49
+ } catch { return null; }
50
+
51
+ const lines = buf.split("\n").filter(Boolean);
52
+ for (let i = lines.length - 1; i >= 0; i--) {
53
+ let r; try { r = JSON.parse(lines[i]); } catch { continue; }
54
+ const u = r?.message?.usage;
55
+ if (r?.type === "assistant" && u) {
56
+ const tokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
57
+ if (tokens <= 0) continue;
58
+ const model = r.message.model || "";
59
+ const window = resolveWindow(model, conf);
60
+ return { tokens, window, frac: window ? tokens / window : null, model };
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ // The transcript logs the model WITHOUT the [1m] marker, so we cannot tell a
67
+ // 200k window from a 1M one. There is therefore no safe universal default — the
68
+ // window must be declared (env RELAY_CONTEXT_WINDOW or config.contextWindow) for
69
+ // the proactive early-warning to activate. Returns 0 when unknown (→ no warning).
70
+ export function resolveWindow(model = "", conf = readConfig()) {
71
+ const explicit = Number(process.env.RELAY_CONTEXT_WINDOW || conf.contextWindow || 0);
72
+ if (explicit > 0) return explicit;
73
+ if (/\[1m\]|-1m\b|:1m\b/i.test(model)) return 1_000_000; // honored if ever present
74
+ return 0;
75
+ }
76
+
77
+ export function warnFrac(conf = readConfig()) {
78
+ const f = Number(process.env.RELAY_CONTEXT_WARN_FRAC || conf.contextWarnFrac || 0.85);
79
+ return f > 0 && f < 1 ? f : 0.85;
80
+ }
81
+
82
+ // ---- per-session guard (shared by both paths) -------------------------------
83
+ // One handoff+spawn per context window. Re-arms after a compaction resets the
84
+ // context (tokens drop well below where we fired).
85
+ function guardPath(sessionId) {
86
+ const safe = String(sessionId || "nosession").replace(/[^A-Za-z0-9_.-]/g, "_");
87
+ return join(homedir(), ".agent-bus", `handoff-fired-${safe}.json`);
88
+ }
89
+ export function alreadyHandedOff(sessionId, curTokens = 0) {
90
+ try {
91
+ const p = guardPath(sessionId);
92
+ if (!existsSync(p)) return false;
93
+ const g = JSON.parse(readFileSync(p, "utf8"));
94
+ // Re-arm if context clearly reset (e.g. after a compaction) — well below the fire point.
95
+ if (curTokens && g.atTokens && curTokens < g.atTokens * 0.7) return false;
96
+ return true;
97
+ } catch { return false; }
98
+ }
99
+ export function markHandedOff(sessionId, curTokens = 0) {
100
+ try {
101
+ if (!existsSync(dirname(guardPath(sessionId)))) mkdirSync(dirname(guardPath(sessionId)), { recursive: true });
102
+ writeFileSync(guardPath(sessionId), JSON.stringify({ at: nowSec(), atTokens: curTokens || 0 }));
103
+ } catch {}
104
+ }
105
+
106
+ function nowSec() { try { return Number(execSync("date +%s", { encoding: "utf8" }).trim()) || 0; } catch { return 0; } }
107
+
108
+ // ---- whole-session summary --------------------------------------------------
109
+ function collectTurns(transcriptPath) {
110
+ const rows = readFileSync(transcriptPath, "utf8").split("\n").filter(Boolean)
111
+ .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
112
+ const turns = [];
113
+ for (const r of rows) {
114
+ if (!(r.type === "user" || r.type === "assistant") || !r.message) continue;
115
+ const c = r.message.content;
116
+ let text = "";
117
+ if (typeof c === "string") text = c;
118
+ else if (Array.isArray(c)) text = c.filter(b => b?.type === "text").map(b => b.text).join("\n");
119
+ text = (text || "").trim();
120
+ if (!text || text.startsWith("<task-notification") || text.startsWith("<command")) continue;
121
+ turns.push(`### ${r.type.toUpperCase()}\n${text.slice(0, 2400)}`);
122
+ }
123
+ return turns;
124
+ }
125
+
126
+ // Build a digest that spans the WHOLE session: the opening turns (the task &
127
+ // goal framing), an even sample of the middle (the arc of the work), and a
128
+ // fuller recent tail (current state). The old hook kept only the last 16KB —
129
+ // on a multi-hour session that captured only the final moments.
130
+ function digest(turns, budget = 56_000) {
131
+ const joined = turns.join("\n\n");
132
+ if (joined.length <= budget) return joined;
133
+
134
+ const headN = Math.min(6, turns.length);
135
+ const tailN = Math.min(24, Math.max(0, turns.length - headN));
136
+ const head = turns.slice(0, headN);
137
+ const tail = turns.slice(turns.length - tailN);
138
+ const midPool = turns.slice(headN, turns.length - tailN);
139
+
140
+ // Evenly sample the middle so the summarizer sees the whole trajectory.
141
+ const midKeep = 18;
142
+ const mid = [];
143
+ if (midPool.length > 0) {
144
+ const step = Math.max(1, Math.floor(midPool.length / midKeep));
145
+ for (let i = 0; i < midPool.length && mid.length < midKeep; i += step) mid.push(midPool[i]);
146
+ }
147
+ let out = [
148
+ ...head,
149
+ midPool.length ? "### … (mid-session, evenly sampled) …" : "",
150
+ ...mid,
151
+ tail.length ? "### … (recent) …" : "",
152
+ ...tail,
153
+ ].filter(Boolean).join("\n\n");
154
+ if (out.length > budget) out = out.slice(out.length - budget); // never blow the budget
155
+ return out;
156
+ }
157
+
158
+ function haveScrooge() {
159
+ if (process.env.TRANTOR_NO_SCROOGE === "1") return false; // opt out (tests / no-LLM summary)
160
+ try { execSync("command -v scrooge", { stdio: "ignore" }); return true; } catch { return false; }
161
+ }
162
+
163
+ export function buildSummary(transcriptPath) {
164
+ if (!transcriptPath || !existsSync(transcriptPath)) return "*(no transcript available to summarize)*";
165
+ let convo = "";
166
+ try { convo = digest(collectTurns(transcriptPath)); } catch { convo = ""; }
167
+ if (!convo) return "*(transcript unreadable)*";
168
+ const sys = "You are writing a SESSION HANDOFF so a fresh Claude Code session can take over without losing context. The text spans an entire (possibly multi-hour) session: opening turns, an even sample of the middle, and the recent tail. Produce a concise but COMPLETE markdown handoff with these sections: TASK (what we're doing + the goal), STATE (done / in-progress), KEY DECISIONS, OPEN THREADS & NEXT STEPS (concrete actions), KEY FILES & locations (exact paths). Be specific. Cover the whole arc, not just the end. Do not pad.";
169
+ if (haveScrooge()) {
170
+ try {
171
+ return execSync(`scrooge -t summarize -d medium --system ${JSON.stringify(sys)}`, {
172
+ input: convo, encoding: "utf8", timeout: 60_000, maxBuffer: 8 * 1024 * 1024,
173
+ }).trim() || `*(empty summary — raw recent tail)*\n\n${convo.slice(-8000)}`;
174
+ } catch (e) { process.stderr.write(`[trantor] scrooge summarize failed: ${e?.message}\n`); }
175
+ }
176
+ return `*(no summarizer available — representative transcript digest)*\n\n${convo.slice(-12000)}`;
177
+ }
178
+
179
+ // ---- write + announce + spawn ----------------------------------------------
180
+ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary }) {
181
+ const projectName = basename(projectDir);
182
+ if (!existsSync(HANDOFF_DIR)) mkdirSync(HANDOFF_DIR, { recursive: true });
183
+ const stamp = nowSec() || Date.now();
184
+ let gitStatus = "";
185
+ try { gitStatus = execSync("git -C " + JSON.stringify(projectDir) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
186
+ const record = {
187
+ id: `${projectName}-${stamp}`,
188
+ project: projectDir, projectName, machine: hostname(),
189
+ session_id: sessionId || "", trigger: trigger || "auto",
190
+ transcript_path: transcript || "", stamp: Number(stamp) || 0,
191
+ summary: summary ?? buildSummary(transcript),
192
+ gitStatus, consumed: false,
193
+ };
194
+ const file = join(HANDOFF_DIR, `${record.id}.json`);
195
+ writeFileSync(file, JSON.stringify(record, null, 2));
196
+ return { file, record };
197
+ }
198
+
199
+ export async function pingBus(projectName, id, conf = readConfig()) {
200
+ try {
201
+ await fetch(`${relayUrl(conf)}/send`, {
202
+ method: "POST", headers: { "content-type": "application/json" },
203
+ body: JSON.stringify({ from: `${hostname()}:${projectName}`, to: "all",
204
+ text: `📋 Handoff ready for ${projectName} — open a fresh session here to take over (id ${id}).` }),
205
+ signal: AbortSignal.timeout(2000),
206
+ }).catch(() => {});
207
+ } catch {}
208
+ }
209
+
210
+ // Spawn a fresh same-agent session (macOS) that takes over via the handoff.
211
+ // Default = ON (prompt with a timeout, default button "Open fresh session").
212
+ // Disable with config.autoHandoffPrompt:false or env TRANTOR_NO_HANDOFF_SPAWN=1.
213
+ export function maybeSpawn(projectDir, conf = readConfig()) {
214
+ try {
215
+ if (process.platform !== "darwin") return false;
216
+ if (process.env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
217
+ if (conf.autoHandoffPrompt === false) return false;
218
+ const script = join(HERE, "..", "..", "bin", "handoff-prompt.sh");
219
+ if (!existsSync(script)) { process.stderr.write(`[trantor] handoff-prompt.sh missing\n`); return false; }
220
+ const timeout = String(conf.handoffPromptTimeout || 25);
221
+ const child = spawn("/bin/bash", [script, projectDir, timeout], { detached: true, stdio: "ignore" });
222
+ child.unref();
223
+ return true;
224
+ } catch (e) { process.stderr.write(`[trantor] maybeSpawn error: ${e?.message}\n`); return false; }
225
+ }
@@ -1,18 +1,14 @@
1
1
  #!/usr/bin/env node
2
- // trantor PreCompact hook — fires right before Claude Code compacts a full
3
- // context window. Instead of (just) compacting, it writes a rich HANDOFF so you can
4
- // open a FRESH session that takes over with a new full window. The SessionStart hook
5
- // detects the pending handoff and loads it.
6
- //
7
- // Handoff generation: if `scrooge` is on PATH, it summarizes the recent transcript
8
- // into a structured handoff cheaply; otherwise it falls back to a raw transcript tail.
9
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
10
- import { join, basename, dirname } from "node:path";
11
- import { homedir, hostname } from "node:os";
12
- import { execSync, spawn } from "node:child_process";
13
- import { fileURLToPath } from "node:url";
14
-
15
- const HANDOFF_DIR = join(homedir(), ".agent-bus", "handoffs");
2
+ // trantor PreCompact hook — fires right before Claude Code compacts a full context
3
+ // window. A PreCompact hook CANNOT stop compaction; the current window is always
4
+ // compacted. So its job is to write a rich WHOLE-SESSION handoff and (on macOS, by
5
+ // default) prompt to open a FRESH session in a new terminal that takes over with a
6
+ // full window. The new session's SessionStart hook loads the handoff. This is the
7
+ // at-the-wall backstop; the heartbeat hook can also fire this earlier when the
8
+ // context window size is known (see hooks/lib/handoff.mjs).
9
+ import { readConfig, writeHandoff, pingBus, maybeSpawn,
10
+ contextUsage, alreadyHandedOff, markHandedOff } from "./lib/handoff.mjs";
11
+ import { basename } from "node:path";
16
12
 
17
13
  function readStdin() {
18
14
  return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
@@ -20,97 +16,29 @@ function readStdin() {
20
16
  setTimeout(() => res(d), 100); });
21
17
  }
22
18
 
23
- // Pull readable recent conversation text from a Claude Code transcript JSONL.
24
- function recentTranscript(path, maxChars = 16000) {
25
- try {
26
- const rows = readFileSync(path, "utf8").split("\n").filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
27
- const turns = [];
28
- for (const r of rows) {
29
- if (!(r.type === "user" || r.type === "assistant") || !r.message) continue;
30
- const c = r.message.content;
31
- let text = "";
32
- if (typeof c === "string") text = c;
33
- else if (Array.isArray(c)) text = c.filter(b => b?.type === "text").map(b => b.text).join("\n");
34
- if (text.trim() && !text.startsWith("<task-notification") && !text.startsWith("<command")) {
35
- turns.push(`### ${r.type.toUpperCase()}\n${text.slice(0, 2000)}`);
36
- }
37
- }
38
- let out = turns.join("\n\n");
39
- if (out.length > maxChars) out = out.slice(out.length - maxChars); // keep the most recent
40
- return out;
41
- } catch { return ""; }
42
- }
43
-
44
- function haveScrooge() { try { execSync("command -v scrooge", { stdio: "ignore" }); return true; } catch { return false; } }
45
-
46
- function summarize(convo) {
47
- const sys = "You are writing a SESSION HANDOFF so a fresh Claude Code session can take over without losing context. From the conversation, produce a concise but complete markdown handoff with these sections: TASK (what we're doing + the goal), STATE (done / in-progress), KEY DECISIONS, OPEN THREADS & NEXT STEPS (concrete actions), KEY FILES & locations (exact paths). Be specific. Do not pad.";
48
- if (haveScrooge()) {
49
- try {
50
- return execSync(`scrooge -t summarize -d medium --system ${JSON.stringify(sys)}`, {
51
- input: convo, encoding: "utf8", timeout: 45000, maxBuffer: 4 * 1024 * 1024,
52
- }).trim();
53
- } catch (e) { process.stderr.write(`[trantor] scrooge summarize failed: ${e?.message}\n`); }
54
- }
55
- // fallback: raw recent tail
56
- return `*(no summarizer available — raw recent transcript tail)*\n\n${convo.slice(-6000)}`;
57
- }
58
-
59
19
  try {
60
20
  const input = JSON.parse((await readStdin()) || "{}");
61
21
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
62
22
  const projectName = basename(projectDir);
63
23
  const transcript = input.transcript_path || "";
64
24
  const trigger = input.trigger || "auto";
25
+ const sessionId = input.session_id || "";
26
+ const conf = readConfig();
65
27
 
66
- const convo = transcript && existsSync(transcript) ? recentTranscript(transcript) : "";
67
- const summary = convo ? summarize(convo) : "*(no transcript available to summarize)*";
68
-
69
- let gitStatus = "";
70
- try { gitStatus = execSync("git -C " + JSON.stringify(projectDir) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
71
-
72
- if (!existsSync(HANDOFF_DIR)) mkdirSync(HANDOFF_DIR, { recursive: true });
73
- const stamp = (() => { try { return execSync("date +%s", { encoding: "utf8" }).trim(); } catch { return String(process.pid); } })();
74
-
75
- const record = {
76
- id: `${projectName}-${stamp}`,
77
- project: projectDir, projectName,
78
- machine: hostname(),
79
- session_id: input.session_id || "",
80
- trigger, transcript_path: transcript,
81
- stamp: Number(stamp) || 0,
82
- summary,
83
- gitStatus,
84
- consumed: false,
85
- };
86
- const file = join(HANDOFF_DIR, `${record.id}.json`);
87
- writeFileSync(file, JSON.stringify(record, null, 2));
28
+ const { file, record } = writeHandoff({ projectDir, sessionId, transcript, trigger });
88
29
  process.stderr.write(`[trantor] handoff written: ${file} (trigger=${trigger})\n`);
89
30
 
90
- // best-effort: ping the relay hub so other sessions/machines know a handoff is ready
91
- try {
92
- const cfg = join(homedir(), ".agent-bus", "config.json");
93
- const url = process.env.RELAY_URL || (existsSync(cfg) ? JSON.parse(readFileSync(cfg, "utf8")).url : "") || "http://127.0.0.1:4477";
94
- await fetch(`${url}/send`, { method: "POST", headers: { "content-type": "application/json" },
95
- body: JSON.stringify({ from: `${hostname()}:${projectName}`, to: "all", text: `📋 Handoff ready for ${projectName} — open a fresh session here to take over (id ${record.id}).` }),
96
- signal: AbortSignal.timeout(2000) }).catch(() => {});
97
- } catch {}
31
+ await pingBus(projectName, record.id, conf);
98
32
 
99
- // OPT-IN: on macOS, if config.autoHandoffPrompt is true, ask the user (with a timeout,
100
- // default = yes) whether to spawn a FRESH same-agent session that takes over via the
101
- // handoff. Detached so it never blocks compaction. Off by default.
102
- try {
103
- const cfg = join(homedir(), ".agent-bus", "config.json");
104
- const conf = existsSync(cfg) ? JSON.parse(readFileSync(cfg, "utf8")) : {};
105
- if (conf.autoHandoffPrompt && process.platform === "darwin") {
106
- const script = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "handoff-prompt.sh");
107
- if (existsSync(script)) {
108
- const child = spawn("/bin/bash", [script, projectDir, String(conf.handoffPromptTimeout || 25)], { detached: true, stdio: "ignore" });
109
- child.unref();
110
- process.stderr.write(`[trantor] handoff prompt launched (opt-in)\n`);
111
- }
112
- }
113
- } catch {}
33
+ // Spawn a fresh session UNLESS the heartbeat early-warning already did so for this
34
+ // window (shared guard). The handoff file is always refreshed above regardless.
35
+ const cur = contextUsage(transcript, conf)?.tokens || 0;
36
+ if (alreadyHandedOff(sessionId, cur)) {
37
+ process.stderr.write(`[trantor] fresh session already spawned for this window — handoff refreshed only\n`);
38
+ } else if (maybeSpawn(projectDir, conf)) {
39
+ markHandedOff(sessionId, cur);
40
+ process.stderr.write(`[trantor] fresh-session prompt launched (PreCompact)\n`);
41
+ }
114
42
  } catch (err) {
115
43
  process.stderr.write(`[trantor] precompact error: ${err?.message || err}\n`);
116
44
  }
@@ -9,9 +9,15 @@
9
9
  import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
10
10
  import { join, basename } from "node:path";
11
11
  import { homedir, hostname } from "node:os";
12
+ import { execSync } from "node:child_process";
13
+ import { resolveProject } from "../lib/project.mjs";
12
14
 
13
- // Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs).
14
- function loadPendingHandoff(projectName) {
15
+ // Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
16
+ // / the heartbeat early-warning). `claim` marks it consumed so exactly one session
17
+ // takes it. A compaction-triggered SessionStart (source="compact") is the SAME session
18
+ // that just wrote the handoff for a FRESH window to pick up — it may show the summary
19
+ // for continuity but must NOT claim it, or it steals the handoff from the new window.
20
+ function loadPendingHandoff(projectName, { claim = true } = {}) {
15
21
  try {
16
22
  const dir = join(homedir(), ".agent-bus", "handoffs");
17
23
  if (!existsSync(dir)) return null;
@@ -20,7 +26,7 @@ function loadPendingHandoff(projectName) {
20
26
  const p = join(dir, f);
21
27
  const rec = JSON.parse(readFileSync(p, "utf8"));
22
28
  if (!rec.consumed) {
23
- rec.consumed = true; writeFileSync(p, JSON.stringify(rec, null, 2)); // claim it
29
+ if (claim) { rec.consumed = true; writeFileSync(p, JSON.stringify(rec, null, 2)); }
24
30
  return rec;
25
31
  }
26
32
  }
@@ -59,7 +65,8 @@ function sanitize(s) {
59
65
 
60
66
  let additionalContext = "";
61
67
  try {
62
- await readStdin();
68
+ let source = "";
69
+ try { source = (JSON.parse((await readStdin()) || "{}").source) || ""; } catch {}
63
70
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
64
71
  // Sessions started in the home directory itself aren't project work — registering
65
72
  // them spawns a phantom "<username>" project board on the dashboard. Set
@@ -69,7 +76,7 @@ try {
69
76
  process.stdout.write("{}");
70
77
  process.exit(0);
71
78
  }
72
- const project = process.env.RELAY_PROJECT || basename(projectDir);
79
+ const project = resolveProject(projectDir);
73
80
  const session = process.env.RELAY_SESSION
74
81
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
75
82
  const url = relayUrl();
@@ -92,11 +99,44 @@ try {
92
99
  additionalContext += `</trantor>\n`;
93
100
  }
94
101
 
102
+ // CATCH-UP: a project is a DURABLE, continuous lane — not a session. Before doing
103
+ // anything, this session reconciles with the living board: what's been built, what's
104
+ // in flight, what's queued, plus the latest commits. So a fresh window resumes the
105
+ // SAME project where it stands instead of starting blind. Cheap + LLM-free.
106
+ try {
107
+ const cu = await jget(`${url}/catchup?project=${encodeURIComponent(project)}`).catch(() => null);
108
+ let gitlog = "";
109
+ try { gitlog = execSync(`git -C ${JSON.stringify(projectDir)} log --oneline -5 2>/dev/null`, { encoding: "utf8", timeout: 2500 }).trim(); } catch {}
110
+ if ((cu && cu.total > 0) || gitlog) {
111
+ const line = (arr) => (arr || []).map(t => `#${t.id} ${String(t.title).slice(0, 72)}${t.assignee ? ` @${t.assignee}` : ""}`).join("\n ");
112
+ additionalContext += `<trantor-project-state project="${sanitize(project)}">\n`;
113
+ additionalContext += `📋 **Catching up on the continuous "${sanitize(project)}" board** (this project's living record across all sessions — read it before starting; don't duplicate done work).\n`;
114
+ if (cu && cu.brief) additionalContext += `\n**Brief:** ${sanitize(cu.brief)}\n`;
115
+ if (cu && cu.total > 0) {
116
+ const c = cu.counts;
117
+ additionalContext += `\n**Cards:** ${cu.total} total — ${c.done} done · ${c.doing} doing · ${c.testing} testing · ${c.todo} todo · ${c.failed} failed · ${c.blocked} blocked.\n`;
118
+ if (cu.doing?.length) additionalContext += `\n_In progress:_\n ${sanitize(line(cu.doing))}\n`;
119
+ if (cu.testing?.length) additionalContext += `\n_In testing:_\n ${sanitize(line(cu.testing))}\n`;
120
+ if (cu.failed?.length) additionalContext += `\n_Failed (needs attention):_\n ${sanitize(line(cu.failed))}\n`;
121
+ if (cu.blocked?.length) additionalContext += `\n_Blocked:_\n ${sanitize(line(cu.blocked))}\n`;
122
+ if (cu.todo?.length) additionalContext += `\n_Queued (todo):_\n ${sanitize(line(cu.todo))}\n`;
123
+ if (cu.recentDone?.length) additionalContext += `\n_Recently done:_\n ${sanitize(line(cu.recentDone))}\n`;
124
+ }
125
+ if (gitlog) additionalContext += `\n**Recent commits:**\n\`\`\`\n${sanitize(gitlog)}\n\`\`\`\n`;
126
+ additionalContext += `\nFor a synthesized "where are we" narrative on demand, run \`trantor catchup\`.\n`;
127
+ additionalContext += `</trantor-project-state>\n`;
128
+ process.stderr.write(`[trantor] injected project-state catch-up for ${project} (${cu?.total || 0} cards)\n`);
129
+ }
130
+ } catch {}
131
+
95
132
  // Pending handoff? A prior session hit the context limit and left a handoff for this
96
- // project — take over with this fresh full window instead of starting cold.
97
- const handoff = loadPendingHandoff(basename(projectDir));
133
+ // project — take over with this fresh full window instead of starting cold. On a
134
+ // compaction-triggered start, DON'T claim it (that's the same session that wrote it;
135
+ // claiming would steal it from the freshly-spawned window) — show it for continuity only.
136
+ const isCompact = source === "compact";
137
+ const handoff = loadPendingHandoff(basename(projectDir), { claim: !isCompact });
98
138
  if (handoff) {
99
- process.stderr.write(`[trantor] loaded pending handoff ${handoff.id}\n`);
139
+ process.stderr.write(`[trantor] ${isCompact ? "showing (not claiming, compact)" : "loaded"} pending handoff ${handoff.id}\n`);
100
140
  additionalContext += `<trantor-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}">\n`;
101
141
  additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.\n\n`;
102
142
  additionalContext += `## Handoff summary\n${sanitize(handoff.summary)}\n`;
package/hub.mjs CHANGED
@@ -50,11 +50,11 @@ function scanTelemetry() {
50
50
 
51
51
  // peers: { session: { lastSeen, status, project } } ; tasks: kanban cards
52
52
  // projectMeta: { project: { brief, by, updated } } — the "what & why" blurb per project
53
- let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [], cardEventsBackfilled: false };
53
+ let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [], cardEventsBackfilled: false, aliases: {} };
54
54
  try {
55
55
  if (existsSync(DATA)) {
56
56
  const loaded = JSON.parse(readFileSync(DATA, "utf8"));
57
- state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [], cardEvents: Array.isArray(loaded.cardEvents) ? loaded.cardEvents : [], cardEventsBackfilled: !!loaded.cardEventsBackfilled };
57
+ state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [], cardEvents: Array.isArray(loaded.cardEvents) ? loaded.cardEvents : [], cardEventsBackfilled: !!loaded.cardEventsBackfilled, aliases: (loaded.aliases && typeof loaded.aliases === "object") ? loaded.aliases : {} };
58
58
  for (const [s, v] of Object.entries(loaded.peers || {})) // migrate old numeric form
59
59
  state.peers[s] = typeof v === "number" ? { lastSeen: v, status: "", project: "" } : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "" };
60
60
  }
@@ -104,14 +104,23 @@ const now = () => Date.now();
104
104
  const fmtAge = ms => { const m = Math.floor(ms / 60000); return m > 48 * 60 ? `${Math.floor(m / 1440)}d ago` : m > 90 ? `${Math.floor(m / 60)}h ago` : `${m}m ago`; };
105
105
  function body(req) { return new Promise(r => { let d = ""; req.on("data", c => (d += c)); req.on("end", () => { try { r(d ? JSON.parse(d) : {}); } catch { r({}); } }); }); }
106
106
  function json(res, code, obj) { res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": "*" }); res.end(JSON.stringify(obj)); }
107
+ // Canonical project name: follow the alias chain so historically-divergent keys
108
+ // (e.g. "builtbetter" → "builtbetter.ai") fold into one lane on every read AND
109
+ // write. Cycle-guarded. Empty/"all" pass through untouched.
110
+ function canon(name) {
111
+ let n = String(name || "").slice(0, 80);
112
+ const seen = new Set();
113
+ while (n && state.aliases[n] && !seen.has(n)) { seen.add(n); n = state.aliases[n]; }
114
+ return n;
115
+ }
107
116
  function touch(session, status, project) {
108
117
  if (!session || session === "all") return; // "all" is a wildcard, not a real peer
109
118
  const p = state.peers[session] || { lastSeen: 0, status: "", project: "" };
110
119
  p.lastSeen = now();
111
120
  if (status !== undefined) p.status = String(status).slice(0, 280);
112
- if (project) p.project = String(project).slice(0, 80);
121
+ if (project) p.project = canon(String(project).slice(0, 80));
113
122
  // derive project from a "host:project" session id if none given
114
- if (!p.project && session.includes(":")) p.project = session.split(":").pop().slice(0, 80);
123
+ if (!p.project && session.includes(":")) p.project = canon(session.split(":").pop().slice(0, 80));
115
124
  state.peers[session] = p; dirty = true;
116
125
  }
117
126
  // Derive a coarse health from the free-text status the runner sets on a failed turn
@@ -159,7 +168,7 @@ const server = http.createServer(async (req, res) => {
159
168
  if (req.method === "POST" && P === "/task") { // create a card
160
169
  const b = await body(req); touch(b.by, undefined, b.project);
161
170
  const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
162
- const t = { id: ++state.taskSeq, project: String(b.project || "").slice(0,80), title: String(b.title||"").slice(0,200),
171
+ const t = { id: ++state.taskSeq, project: canon(String(b.project || "").slice(0,80)), title: String(b.title||"").slice(0,200),
163
172
  assignee: b.assignee || "", status: st0,
164
173
  difficulty: ["easy","medium","hard"].includes(b.difficulty) ? b.difficulty : "",
165
174
  model: String(b.model || "").slice(0, 60),
@@ -196,7 +205,7 @@ const server = http.createServer(async (req, res) => {
196
205
  if (req.method === "POST" && P === "/todos") {
197
206
  const b = await body(req);
198
207
  const session = String(b.session || b.by || "").slice(0, 120);
199
- const project = String(b.project || "").slice(0, 80);
208
+ const project = canon(String(b.project || "").slice(0, 80));
200
209
  if (!session || !project) return json(res, 400, { error: "session and project required" });
201
210
  touch(session, undefined, project);
202
211
  const ST = { pending: "todo", in_progress: "doing", completed: "done" };
@@ -228,13 +237,14 @@ const server = http.createServer(async (req, res) => {
228
237
  return json(res, 200, { ok: true, count: todos.length });
229
238
  }
230
239
  if (req.method === "GET" && P === "/tasks") {
231
- const proj = q.project; const ts = proj ? state.tasks.filter(t => t.project === proj) : state.tasks;
240
+ const proj = q.project ? canon(q.project) : ""; const ts = proj ? state.tasks.filter(t => canon(t.project) === proj) : state.tasks;
232
241
  return json(res, 200, { tasks: ts });
233
242
  }
234
243
  if (req.method === "GET" && P === "/history") {
235
244
  const requestedLimit = Number(q.limit || 200);
236
245
  const limit = Math.min(Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 200, 0), 1000);
237
- const events = (q.project ? state.cardEvents.filter(e => e.project === q.project) : state.cardEvents).slice(-limit);
246
+ const proj = q.project ? canon(q.project) : "";
247
+ const events = (proj ? state.cardEvents.filter(e => canon(e.project) === proj) : state.cardEvents).slice(-limit);
238
248
  return json(res, 200, { events });
239
249
  }
240
250
  // A single card's FULL story for the detail panel: the card itself, its status events, and the
@@ -252,7 +262,7 @@ const server = http.createServer(async (req, res) => {
252
262
  return json(res, 200, { task: meta, events, messages });
253
263
  }
254
264
  if (req.method === "POST" && P === "/project") { // set a project's brief (what & why)
255
- const b = await body(req); const k = String(b.project || "").slice(0, 80);
265
+ const b = await body(req); const k = canon(String(b.project || "").slice(0, 80));
256
266
  if (!k) return json(res, 400, { error: "project required" });
257
267
  const m = state.projectMeta[k] || {};
258
268
  if (b.brief !== undefined) m.brief = String(b.brief).slice(0, 600);
@@ -271,10 +281,52 @@ const server = http.createServer(async (req, res) => {
271
281
  dirty = true; // the project reappears cleanly if an agent ever registers it again
272
282
  return json(res, 200, { ok: true, project: k, removed: { tasks: nt - state.tasks.length, peers: np - Object.keys(state.peers).length, messages: nm - state.messages.length } });
273
283
  }
284
+ // Fold one project lane into another: rewrite all stored project fields from→to AND
285
+ // record an alias so future writes under `from` canonicalize to `to`. Idempotent.
286
+ // This is how a fragmented project (one repo, two lane keys) becomes one continuous lane.
287
+ if (req.method === "POST" && P === "/project/merge") {
288
+ const b = await body(req);
289
+ const from = String(b.from || "").slice(0, 80), to = String(b.to || "").slice(0, 80);
290
+ if (!from || !to || from === to) return json(res, 400, { error: "distinct from+to required" });
291
+ let cards = 0, events = 0, peers = 0, msgs = 0;
292
+ for (const t of state.tasks) if (t.project === from) { t.project = to; cards++; }
293
+ for (const e of state.cardEvents) if (e.project === from) { e.project = to; events++; }
294
+ for (const v of Object.values(state.peers)) if (v.project === from) { v.project = to; peers++; }
295
+ for (const m of state.messages) if ((m.project || "") === from) { m.project = to; msgs++; }
296
+ if (state.projectMeta[from]) {
297
+ if (!state.projectMeta[to]) state.projectMeta[to] = state.projectMeta[from];
298
+ else if (!state.projectMeta[to].brief && state.projectMeta[from].brief) state.projectMeta[to].brief = state.projectMeta[from].brief;
299
+ delete state.projectMeta[from];
300
+ }
301
+ state.aliases[from] = to; // future writes fold automatically
302
+ for (const [k, v] of Object.entries(state.aliases)) if (v === from) state.aliases[k] = to; // re-point chains
303
+ dirty = true;
304
+ return json(res, 200, { ok: true, from, to, moved: { cards, events, peers, messages: msgs } });
305
+ }
306
+ // Catch-up snapshot: everything a NEW session needs to resume a project's continuous
307
+ // lane — the brief, card counts, what's in-flight (doing/testing/todo) and the most
308
+ // recent done work, plus last activity. Cheap + LLM-free; the SessionStart hook injects it.
309
+ if (req.method === "GET" && P === "/catchup") {
310
+ const proj = canon(q.project || "");
311
+ if (!proj) return json(res, 400, { error: "project required" });
312
+ const mine = state.tasks.filter(t => canon(t.project) === proj);
313
+ const counts = { todo:0, doing:0, testing:0, failed:0, done:0, blocked:0 };
314
+ for (const t of mine) counts[t.status] = (counts[t.status] || 0) + 1;
315
+ const pick = (st, n) => mine.filter(t => t.status === st).sort((a,b)=>(b.updated||0)-(a.updated||0)).slice(0, n)
316
+ .map(t => ({ id: t.id, title: t.title, assignee: t.assignee || "", updated: t.updated || 0 }));
317
+ const lastActivity = mine.reduce((mx,t)=>Math.max(mx, t.updated||0), state.projectMeta[proj]?.updated || 0);
318
+ return json(res, 200, {
319
+ project: proj, brief: state.projectMeta[proj]?.brief || "",
320
+ counts, total: mine.length,
321
+ doing: pick("doing", 8), testing: pick("testing", 8), failed: pick("failed", 8),
322
+ blocked: pick("blocked", 8), todo: pick("todo", 10), recentDone: pick("done", 8),
323
+ lastActivity,
324
+ });
325
+ }
274
326
  if (req.method === "GET" && P === "/projects") { // project-grouped view
275
327
  prunePeers();
276
328
  const cutoff = now() - ONLINE_MS; const byProj = {};
277
- const proj = p => p || "(unassigned)";
329
+ const proj = p => canon(p) || "(unassigned)";
278
330
  const mk = k => (byProj[k] ||= { project: k, brief: (state.projectMeta[k]?.brief) || "", agents: [], tasks: { todo:0,doing:0,testing:0,failed:0,done:0,blocked:0 }, doingTitles: [], lastActivity: 0 });
279
331
  for (const [s, v] of Object.entries(state.peers)) {
280
332
  const k = proj(v.project); const e = mk(k); e.agents.push({ session: s, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status) });
@@ -0,0 +1,23 @@
1
+ // trantor — canonical project identity (client side).
2
+ // One repo = one lane. The loose `basename(cwd)` used everywhere before let a
3
+ // project fragment into multiple lanes (e.g. the host registered "builtbetter.ai"
4
+ // while its crew registered "builtbetter"). We now key by the GIT REPO ROOT
5
+ // basename, which is stable across subdirectories and sessions. An explicit
6
+ // RELAY_PROJECT always wins (deliberate override / crew inheritance). The hub
7
+ // applies an alias map on top of this to fold any historical divergence.
8
+ import { execSync } from "node:child_process";
9
+ import { basename } from "node:path";
10
+
11
+ export function gitRoot(dir) {
12
+ try {
13
+ return execSync(`git -C ${JSON.stringify(dir)} rev-parse --show-toplevel`,
14
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 }).trim();
15
+ } catch { return ""; }
16
+ }
17
+
18
+ // Stable project key for a working directory. RELAY_PROJECT > git-root basename > cwd basename.
19
+ export function resolveProject(cwd = process.cwd()) {
20
+ if (process.env.RELAY_PROJECT) return process.env.RELAY_PROJECT.slice(0, 80);
21
+ const root = gitRoot(cwd);
22
+ return basename(root || cwd).slice(0, 80);
23
+ }
package/mcp.mjs CHANGED
@@ -10,6 +10,7 @@ import { join, basename } from "node:path";
10
10
  import { homedir, hostname } from "node:os";
11
11
  import { execSync, spawnSync } from "node:child_process";
12
12
  import { advise } from "./bin/advise.mjs";
13
+ import { resolveProject } from "./lib/project.mjs";
13
14
  import { z } from "zod";
14
15
 
15
16
  function relayUrl() {
@@ -21,7 +22,9 @@ function relayUrl() {
21
22
  return "http://127.0.0.1:4477";
22
23
  }
23
24
  const URL_BASE = relayUrl();
24
- const PROJECT = process.env.RELAY_PROJECT || basename(process.env.CLAUDE_PROJECT_DIR || process.cwd());
25
+ // Stable project key: RELAY_PROJECT > git-repo-root basename > cwd basename. Keying by
26
+ // the git root (not a loose cwd basename) stops one repo fragmenting into several lanes.
27
+ const PROJECT = resolveProject(process.env.CLAUDE_PROJECT_DIR || process.cwd());
25
28
  // Identity: RELAY_SESSION wins; else RELAY_AGENT ("codex", "kimi", …) brands the session per-project
26
29
  // (set it once in the CLI's global MCP config — works in every project); else hostname:project.
27
30
  const SESSION = process.env.RELAY_SESSION
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.13",
3
+ "version": "0.17.14",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -10,7 +10,7 @@
10
10
  "zod": "^4.4.3"
11
11
  },
12
12
  "scripts": {
13
- "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs"
13
+ "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs"
14
14
  },
15
15
  "description": "The hub-world for AI agent crews \u2014 orchestrate Claude Code, Codex, Gemini, Kimi & DeepSeek as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
16
16
  "files": [
@@ -19,6 +19,7 @@
19
19
  "ui.html",
20
20
  "bin/",
21
21
  "hooks/",
22
+ "lib/",
22
23
  "skills/",
23
24
  "deploy/",
24
25
  "configs/",