trantor 0.17.13 → 0.17.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/bin/catchup.mjs +56 -0
- package/bin/cli.mjs +2 -0
- package/bin/crew-runner.mjs +6 -1
- package/bin/crew.sh +5 -2
- package/hooks/handoff-now.mjs +22 -0
- package/hooks/heartbeat.mjs +52 -5
- package/hooks/lib/handoff.mjs +225 -0
- package/hooks/precompact.mjs +23 -95
- package/hooks/sessionstart.mjs +48 -8
- package/hub.mjs +62 -10
- package/lib/project.mjs +23 -0
- package/mcp.mjs +4 -1
- package/package.json +3 -2
|
@@ -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.
|
|
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.
|
|
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.
|
|
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/bin/catchup.mjs
ADDED
|
@@ -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
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/hooks/precompact.mjs
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// trantor PreCompact hook — fires right before Claude Code compacts a full
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
}
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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
|
-
|
|
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));
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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) });
|
package/lib/project.mjs
ADDED
|
@@ -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
|
-
|
|
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.
|
|
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/",
|