trantor 0.17.12 → 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.12"
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.12",
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.12",
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": {
package/README.md CHANGED
@@ -13,12 +13,13 @@
13
13
 
14
14
  **One Advisor decides how your work runs — solo, cheap inline calls, or a live crew of
15
15
  Claude Code, Codex, Gemini, Kimi & DeepSeek in their own terminal windows — routed by your
16
- actual plans, supervised on a live board, learning from every failure.**
16
+ actual plans, supervised on a live + historical board you can scroll back through, learning
17
+ from every failure.**
17
18
 
18
19
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
19
20
  ![Node](https://img.shields.io/badge/node-%E2%89%A518-339933?logo=node.js&logoColor=white)
20
21
  ![Agents](https://img.shields.io/badge/crew-Claude%20%C2%B7%20Codex%20%C2%B7%20Gemini%20%C2%B7%20Kimi%20%C2%B7%20DeepSeek-D97757)
21
- ![Tests](https://img.shields.io/badge/tests-37%2F37-2DD4BF)
22
+ ![Tests](https://img.shields.io/badge/tests-80%2F80-2DD4BF)
22
23
 
23
24
  </div>
24
25
 
@@ -99,9 +100,11 @@ project takes over with a full window (and a PreCompact hook does this automatic
99
100
  that many seats ("seats follow the work, not the install list"), and a real-money estimate
100
101
  with quota-pool accounting. You say go.
101
102
  2. **Windows open.** `trantor up codex gemini kimi deepseek:deepseek-v4-pro` spawns one titled
102
- terminal window per agent (`agent:model` pins a model), **serialized and then verified on
103
- the bus** the launcher ends with "crew verified" or names the no-shows loudly. The
104
- orchestrator never gets a green lie.
103
+ terminal window per agent. `agent:model` pins a model; `agent:provider --difficulty hard`
104
+ picks the **best live model** for the work at spawn (capability × cost), enumerated from the
105
+ CLI itself — never a guessed endpoint. **Serialized and then verified on the bus** — the
106
+ launcher ends with "crew verified" or names the no-shows loudly. The orchestrator never gets
107
+ a green lie.
105
108
  3. **Work flows over the bus.** Contracts arrive as messages; each agent owns its own files;
106
109
  coordination happens in <280-char messages you can read on the dashboard. Crew members
107
110
  live under a **runner**: the CLI works one turn and exits, the runner long-polls the bus
@@ -110,22 +113,45 @@ project takes over with a full window (and a PreCompact hook does this automatic
110
113
  4. **The board tells the truth.** Cards flow `todo → doing → testing → done` — `testing` is a
111
114
  real gate (tests/typecheck run there); failures turn the card **pulsing red** until the
112
115
  orchestrator bounces them back; demoted cards wear an "↩ bounced" mark with full history.
113
- 5. **It learns.** Failures become lessons (`relay_lesson`), stored on the hub and **injected
116
+ 5. **Failures surface in real time.** A crew agent whose turn fails (credits exhausted, auth,
117
+ crash) no longer re-parks silently — it classifies the failure, posts a ⚠️ to the bus, and
118
+ flips its dashboard chip **red** (escalating to 🛑 if it keeps failing). `trantor swap <old>
119
+ <new>` tears down an exhausted agent and spawns a live-selected replacement, ready for a fresh
120
+ contract.
121
+ 6. **It learns.** Failures become lessons (`relay_lesson`), stored on the hub and **injected
114
122
  into every future crew's prompts** — global or per-CLI. Your crew gets smarter every run.
115
123
 
116
124
  ## The dashboard — `trantor ui`
117
125
 
118
- A live command center at `http://127.0.0.1:4477`, grouped by **project**:
119
-
120
- - **BOARD view** Kanban with the testing gate, difficulty + model badges per card, agent
121
- chips with provider logos, live status, and quota-pool tags.
122
- - **FLOW view** — the same work as an n8n-style dependency graph: parallel streams fanning
123
- out and converging into integration, edges lighting up as work flows (green = done feeding
124
- forward, animated blue = active), **red ↩n loops where work bounced back**. Drag nodes to
125
- rearrange, ⌘+scroll / pinch to zoom, FIT/AUTO controls. Pick per project; your choice sticks.
126
+ A live command center at `http://127.0.0.1:4477`, grouped by **project** — and a *durable,
127
+ self-maintaining record*, not just a snapshot. Dead sessions self-prune (no more graveyard of
128
+ stale boards), and the project order is **stable**: a working board updates in place instead of
129
+ jumping to the top while you're reading it.
130
+
131
+ Three views per project (your choice sticks):
132
+
133
+ - **BOARD** Kanban with the testing gate, difficulty + model badges per card, agent chips with
134
+ provider logos, live status, quota-pool tags, and **red / 🛑 chips for errored / down agents**.
135
+ - **FLOW** — a **development timeline**: every card laid left→right in **build order** across
136
+ **agent lanes**, each card a readable block segmented by the time it spent in each status, with
137
+ dependency edges converging where parallel work merged. Scroll the project's whole history
138
+ left/right. **Click any card** to open its full story — the contract it was given, the agent's
139
+ plan, its build report, the files it changed — reconstructed from that agent's own bus messages.
140
+ - **TIMELINE** — the same history as a chronological event log.
141
+
142
+ Plus:
143
+
144
+ - **🧠 Learning sidebar** — the self-learning loop, made visible: lessons (global / per-agent /
145
+ per-project), **per-LLM reliability** (turns, fail-rate, trend charts) from real turn telemetry,
146
+ and the guardrails baked into each model's prompts. Watch the platform get smarter over time.
147
+ - **🪙 savings pill** — a lifetime running total of what cheap-model routing has saved vs running
148
+ the frontier model, with a selectable window (24h / week / month / quarter / year).
126
149
  - **Per-project conversation lanes** — watch agents negotiate interfaces in context — plus a
127
- global live feed, and a composer so *you* can message the bus (or any single agent).
128
- - **🪙 economics pill** — live Scrooge ledger: real spend, savings vs frontier pricing.
150
+ global live feed and a composer so *you* can message the bus (or any single agent).
151
+
152
+ Every session registers automatically — **crew or not** — and a solo session's own todo list
153
+ shows up on the board as cards, so the dashboard reflects *all* the work on a project, not just
154
+ crew runs.
129
155
 
130
156
  ## The brain — plan-aware economics
131
157
 
@@ -160,7 +186,7 @@ rate, not work rate.
160
186
  | `relay_send(to, text)` / `relay_inbox` / `relay_wait(t)` | Live messaging: direct, read-new, long-poll wake |
161
187
  | `relay_peers` / `relay_status(text)` / `relay_whoami` | Presence: who's alive (honest, heartbeat-backed), doing what |
162
188
  | `relay_project_brief(text)` | The project's what/why on the dashboard |
163
- | `relay_task_add(title, …, difficulty, model, deps)` | Cards with difficulty/model badges + DAG edges for the flow view |
189
+ | `relay_task_add(title, …, difficulty, model, deps, project?)` | Cards with difficulty/model badges + DAG edges; `project` targets another board when you orchestrate from elsewhere |
164
190
  | `relay_task_move(id, status)` | `todo → doing → testing → done` (the gate), `failed`, `blocked` |
165
191
  | `relay_board` | The project's full board, as text |
166
192
  | `relay_scrooge(prompt, task?, difficulty?)` | Fractal cheap-model delegation, with the ledger receipt |
@@ -170,13 +196,15 @@ rate, not work rate.
170
196
  ## The CLI
171
197
 
172
198
  ```
173
- trantor setup | doctor | connect | profile | up <agents…> | down | ui | advise | hub | watch
199
+ trantor setup | doctor | connect | profile | up <agents…> | swap <old> <new> | down | ui | advise | hub | watch
174
200
  ```
175
201
 
176
- `trantor up` notes: `agent:model` pins a model (`deepseek:deepseek-v4-pro`); spawns are
177
- verified on the bus with one retry; geometry auto-detects the screen you're working on
178
- (`CREW_RECT="X,Y,W,H"` to override); `trantor down` kills crew processes via their ttys and
179
- closes windows without macOS "Terminate?" dialogs.
202
+ `trantor up` notes: `agent:model` pins a model (`deepseek:deepseek-v4-pro`); `agent:provider
203
+ --task <k> --difficulty <d>` picks the **best live model** for the work at spawn
204
+ (`opencode:zai-coding-plan --difficulty hard`); spawns are verified on the bus with one retry;
205
+ geometry auto-detects the screen you're working on (`CREW_RECT="X,Y,W,H"` to override). `trantor
206
+ swap <oldAgent> <newSpec>` replaces an exhausted agent with a live-selected one. `trantor down`
207
+ kills crew processes via their ttys and closes windows without macOS "Terminate?" dialogs.
180
208
 
181
209
  ## Works with any MCP agent
182
210
 
@@ -192,10 +220,10 @@ server auto-registers the session, so presence works before the model says a wor
192
220
  │ advise/contracts │ one turn, exit; runner long-polls (free) + resumes with context
193
221
  └───────────┬─────────────┴──────────────┴────────────────┴────────────────┘
194
222
 
195
- hub.mjs ←— plain HTTP + SSE · presence/messages/board/lessons/economics
223
+ hub.mjs ←— plain HTTP + SSE · presence/messages/board/history/lessons/learning/economics
196
224
  (Node built-ins only · state in ~/.agent-bus/bus.json · loopback by default)
197
225
 
198
- dashboard (ui.html) · BOARD/FLOW · conversation lanes · 🪙 ledger
226
+ dashboard (ui.html) · BOARD/FLOW/TIMELINE · 🧠 Learning · 🪙 savings · conversation lanes
199
227
  ```
200
228
 
201
229
  Config: `RELAY_URL` env → `~/.agent-bus/config.json` → `http://127.0.0.1:4477`.
@@ -223,9 +251,10 @@ The `relay_*` tool names and the `~/.agent-bus` state dir remain until a later r
223
251
  ## Tests
224
252
 
225
253
  ```bash
226
- npm test # unit + 37 protocol-level scenario drills with mock agents (no LLMs, seconds, $0):
227
- # honest presence, spawn no-shows, the testing gate, bounce trails, lessons,
228
- # advisor decisions across plan tiers, deps validation, virgin-machine doctor
254
+ npm test # 80 checks: unit + protocol-level scenario drills with mock agents (no LLMs, seconds, $0):
255
+ # honest presence + TTL prune, spawn no-shows, the testing gate, bounce trails, lessons,
256
+ # /history + backfill, /learning shape, /todos sync, /card detail, advisor decisions across
257
+ # plan tiers, deps validation, virgin-machine doctor, and failure-classification drills
229
258
  ```
230
259
 
231
260
  ## License
@@ -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.12",
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/",