trantor 0.17.13 → 0.17.15

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.15"
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.15",
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.15",
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,21 @@
1
+ #!/usr/bin/env node
2
+ // trantor — detached EARLY-WARNING handoff worker. The PostToolUse heartbeat spawns this when a
3
+ // session crosses its context warn threshold (~85% of a known window). Its job is to PREPARE a
4
+ // safety-net handoff and NOTIFY — NOT to open a window. Spawning a fresh session while the original
5
+ // is still perfectly usable created a surprise pop-up + an orphaned duplicate session; the actual
6
+ // fresh-window spawn now happens only AT THE WALL (PreCompact). The (~60s) scrooge summary runs here,
7
+ // detached, so it never blocks a tool call. Args: <projectDir> <sessionId> <transcriptPath> [trigger]
8
+ import { readConfig, writeHandoff, pingBus, contextUsage } from "./lib/handoff.mjs";
9
+ import { basename } from "node:path";
10
+
11
+ const [, , projectDir = process.cwd(), sessionId = "", transcript = "", trigger = "context-warn"] = process.argv;
12
+ try {
13
+ const conf = readConfig();
14
+ const { file, record } = writeHandoff({ projectDir, sessionId, transcript, trigger });
15
+ process.stderr.write(`[trantor] early handoff written (safety net, no spawn): ${file}\n`);
16
+ // notify the bus so a watcher knows a handoff is ready — but do NOT spawn a window here.
17
+ await pingBus(basename(projectDir), record.id, conf);
18
+ } catch (e) {
19
+ process.stderr.write(`[trantor] handoff-now error: ${e?.message || e}\n`);
20
+ }
21
+ 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, hostId } 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
- || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
86
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${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
  }