trantor 0.17.29 → 0.17.31
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/plugin.json +1 -1
- package/bin/agents.mjs +63 -0
- package/bin/baton-close.mjs +36 -6
- package/bin/cli.mjs +2 -0
- package/hooks/handoff-now.mjs +5 -2
- package/hooks/heartbeat.mjs +11 -1
- package/hooks/lib/handoff.mjs +45 -3
- package/hooks/precompact.mjs +5 -3
- package/hooks/sessionstart.mjs +12 -0
- package/lib/subagent-manifest.mjs +175 -0
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.31",
|
|
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/agents.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor agents [sessionId] [--json] — the LIVE sub-agent manifest for a session.
|
|
3
|
+
//
|
|
4
|
+
// What were this session's sub-agents (Agent/Task tool, Workflow swarms, agent-teams) doing —
|
|
5
|
+
// what was each tasked with, did it return, what did it write, and do those files still survive
|
|
6
|
+
// on disk? Derived fresh from the on-disk transcripts every run (so it reflects CURRENT disk,
|
|
7
|
+
// catching files an agent finished that were later clobbered — the 2026-06-21 kill corrupted a
|
|
8
|
+
// completed 30KB lib down to a 17-byte stub).
|
|
9
|
+
//
|
|
10
|
+
// trantor agents → the session of the newest handoff for THIS project (the
|
|
11
|
+
// predecessor a fresh session is taking over from)
|
|
12
|
+
// trantor agents <sessionId> → that specific session
|
|
13
|
+
// trantor agents --json → structured manifest (for tools)
|
|
14
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
15
|
+
import { join, basename } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { deriveSubagentManifest, formatSubagentManifest, resolveTranscriptForSid } from "../lib/subagent-manifest.mjs";
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const json = args.includes("--json");
|
|
21
|
+
const sid = args.find((a) => !a.startsWith("--"));
|
|
22
|
+
|
|
23
|
+
const HANDOFF_DIR = join(process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus"), "handoffs");
|
|
24
|
+
|
|
25
|
+
// Newest handoff record whose project matches the cwd — gives us the predecessor's transcript
|
|
26
|
+
// path + project root directly (no glob needed), so `trantor agents` with no arg "just works"
|
|
27
|
+
// for a fresh session taking over.
|
|
28
|
+
function newestHandoffForCwd() {
|
|
29
|
+
try {
|
|
30
|
+
if (!existsSync(HANDOFF_DIR)) return null;
|
|
31
|
+
const cwd = process.cwd(), name = basename(cwd);
|
|
32
|
+
const recs = readdirSync(HANDOFF_DIR)
|
|
33
|
+
.filter((f) => /-\d+\.json$/.test(f))
|
|
34
|
+
.map((f) => { try { return JSON.parse(readFileSync(join(HANDOFF_DIR, f), "utf8")); } catch { return null; } })
|
|
35
|
+
.filter((r) => r && (r.project === cwd || r.projectName === name))
|
|
36
|
+
.sort((a, b) => (Number(b.stamp) || 0) - (Number(a.stamp) || 0));
|
|
37
|
+
return recs[0] || null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let transcript = "", projectRoot = process.cwd();
|
|
42
|
+
if (sid) {
|
|
43
|
+
transcript = resolveTranscriptForSid(sid);
|
|
44
|
+
if (!transcript) {
|
|
45
|
+
console.error(`No transcript found for session ${sid} under ~/.claude/projects/*/.`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
const h = newestHandoffForCwd();
|
|
50
|
+
if (!h) {
|
|
51
|
+
console.error(`No handoff found for this project. Pass a session id explicitly: trantor agents <sessionId>`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
transcript = h.transcript_path || resolveTranscriptForSid(h.session_id);
|
|
55
|
+
projectRoot = h.project || projectRoot;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const manifest = deriveSubagentManifest(transcript, { projectRoot });
|
|
59
|
+
if (json) {
|
|
60
|
+
process.stdout.write(JSON.stringify(manifest, null, 2) + "\n");
|
|
61
|
+
} else {
|
|
62
|
+
process.stdout.write(formatSubagentManifest(manifest) + "\n");
|
|
63
|
+
}
|
package/bin/baton-close.mjs
CHANGED
|
@@ -2,15 +2,25 @@
|
|
|
2
2
|
// trantor baton-close — the second half of the baton pass. Runs DETACHED, armed by the handoff hook.
|
|
3
3
|
// Waits until the FRESH session has consumed the handoff (consumed:true on the handoff file = it
|
|
4
4
|
// started and loaded the context), THEN closes the ORIGINAL session's Terminal window — so you're never
|
|
5
|
-
// left with two live sessions on one project, and never a gap where neither is alive.
|
|
6
|
-
//
|
|
7
|
-
// NEVER
|
|
8
|
-
|
|
5
|
+
// left with two live sessions on one project, and never a gap where neither is alive.
|
|
6
|
+
//
|
|
7
|
+
// NON-DESTRUCTIVE by contract (incident 2026-06-21): this NEVER force-kills a session. It only ever
|
|
8
|
+
// runs for an OPT-IN auto-close (config.autoCloseOriginal) or a manual /trantor:handoff, and before
|
|
9
|
+
// closing it (a) waits for the fresh session's first real turn, (b) re-validates the window's tty so it
|
|
10
|
+
// can never close the wrong window, and (c) ABORTS if the original session is still working (recent
|
|
11
|
+
// transcript activity or live sub-agents) — leaving it alive for you to close yourself. The old code
|
|
12
|
+
// SIGKILLed every process on the original tty the instant the fresh session emitted one turn, which
|
|
13
|
+
// killed an in-flight 2-agent build mid-flight. Args: <handoffFile> <originalWindowId> <originalTty>
|
|
14
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
9
15
|
import { execSync } from "node:child_process";
|
|
10
16
|
import { pathToFileURL } from "node:url";
|
|
17
|
+
import { subagentsActive } from "../hooks/lib/handoff.mjs";
|
|
11
18
|
|
|
12
19
|
const [, , handoffFile, windowId, originalTty] = process.argv;
|
|
13
20
|
const POLL_MS = 1500, TIMEOUT_MS = 180_000;
|
|
21
|
+
// The original session counts as "still working" if its OWN transcript was written within this window
|
|
22
|
+
// (it's mid-turn / mid-tool-call) — we leave it alone rather than risk closing live work.
|
|
23
|
+
const ORIG_QUIET_MS = 12_000, SUBAGENT_ACTIVE_MS = 90_000;
|
|
14
24
|
// Once the handoff is consumed (= injected into the fresh session's context), wait up to this long for
|
|
15
25
|
// the fresh session to actually PRODUCE its first assistant turn (it boots with `claude 'Recap…'`, so it
|
|
16
26
|
// genuinely reads the handoff and replies) before we close the original. If the fresh session never
|
|
@@ -56,6 +66,18 @@ async function waitForTakeover() {
|
|
|
56
66
|
process.stderr.write(`[trantor] baton-close: fresh session consumed but produced no turn within ${ENGAGE_GRACE_MS / 1000}s — closing anyway (handoff already injected)\n`);
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
// True if the ORIGINAL session is still doing real work — its own transcript was written very recently
|
|
70
|
+
// (mid-turn / mid-tool-call) or it has sub-agents running. We NEVER close a working session; if this is
|
|
71
|
+
// true at takeover time we abort and leave the window alive. Exported for headless regression testing.
|
|
72
|
+
export function originalStillWorking(rec, { quietMs = ORIG_QUIET_MS, subWithinMs = SUBAGENT_ACTIVE_MS } = {}) {
|
|
73
|
+
try {
|
|
74
|
+
const tp = rec?.transcript_path;
|
|
75
|
+
if (!tp || !existsSync(tp)) return false;
|
|
76
|
+
try { if (Date.now() - statSync(tp).mtimeMs < quietMs) return true; } catch {}
|
|
77
|
+
return subagentsActive(tp, subWithinMs);
|
|
78
|
+
} catch { return false; }
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
function ttyOfWindow(id) {
|
|
60
82
|
try {
|
|
61
83
|
return execSync(`osascript -e ${JSON.stringify(`tell application "Terminal" to get tty of selected tab of (first window whose id is ${id})`)}`,
|
|
@@ -70,10 +92,12 @@ function closeWindow(id, tty) {
|
|
|
70
92
|
return false;
|
|
71
93
|
}
|
|
72
94
|
try {
|
|
73
|
-
//
|
|
95
|
+
// Gently ask the processes on that tty to exit (SIGTERM — never SIGKILL; we do not force-kill a
|
|
96
|
+
// session). We only reach here once the original is confirmed idle (no in-flight work), so claude
|
|
97
|
+
// exits cleanly. If something ignores SIGTERM the window close below still tidies up.
|
|
74
98
|
const dev = (tty || cur).replace(/^\/dev\//, "");
|
|
75
99
|
for (const pid of execSync(`ps -t ${dev} -o pid= 2>/dev/null || true`, { encoding: "utf8" }).trim().split("\n").filter(Boolean)) {
|
|
76
|
-
try { execSync(`kill -
|
|
100
|
+
try { execSync(`kill -TERM ${pid.trim()} 2>/dev/null || true`); } catch {}
|
|
77
101
|
}
|
|
78
102
|
} catch {}
|
|
79
103
|
try { execSync(`osascript -e ${JSON.stringify(`tell application "Terminal" to close (first window whose id is ${id})`)}`, { timeout: 3000 }); return true; } catch { return false; }
|
|
@@ -88,6 +112,12 @@ if (isMain) (async () => {
|
|
|
88
112
|
while (Date.now() < deadline) {
|
|
89
113
|
if (consumed()) {
|
|
90
114
|
await waitForTakeover(); // don't close until the fresh session has actually produced its recap turn
|
|
115
|
+
// Final safety gate: never close a session that's still working. If the original is mid-turn or has
|
|
116
|
+
// live sub-agents, abort and leave it alive — the user closes it when ready (incident 2026-06-21).
|
|
117
|
+
if (originalStillWorking(handoff())) {
|
|
118
|
+
process.stderr.write(`[trantor] baton-close: original session still working (recent activity / live sub-agents) — leaving it alive, NOT closing\n`);
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
91
121
|
const ok = closeWindow(windowId, originalTty);
|
|
92
122
|
process.stderr.write(`[trantor] baton-close: fresh session took over → original window ${windowId} ${ok ? "closed" : "left (validation/close failed)"}\n`);
|
|
93
123
|
process.exit(0);
|
package/bin/cli.mjs
CHANGED
|
@@ -26,6 +26,7 @@ switch (cmd) {
|
|
|
26
26
|
case "hub": run("hub.mjs"); break;
|
|
27
27
|
case "watch": run("bin/relay-watch.mjs"); break;
|
|
28
28
|
case "catchup": run("bin/catchup.mjs"); break;
|
|
29
|
+
case "agents": run("bin/agents.mjs"); break;
|
|
29
30
|
case "backfill": run("bin/git-backfill.mjs"); break;
|
|
30
31
|
case "handoff": run("bin/baton.mjs"); break;
|
|
31
32
|
case "ui": {
|
|
@@ -48,6 +49,7 @@ switch (cmd) {
|
|
|
48
49
|
trantor down tear the crew down (kills processes, closes windows, no dialogs)
|
|
49
50
|
trantor ui open the live dashboard (board + flow views)
|
|
50
51
|
trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
|
|
52
|
+
trantor agents what this session's sub-agents did (task · returned? · files written · survived on disk) — [<sessionId>] [--json]
|
|
51
53
|
trantor backfill card past GIT work onto the board (solo commits that were never carded) — [--since "14 days ago"] [--dry-run]
|
|
52
54
|
trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
|
|
53
55
|
trantor advise ask the Advisor directly (JSON on stdin; --demo to see it)
|
package/hooks/handoff-now.mjs
CHANGED
|
@@ -16,8 +16,11 @@ try {
|
|
|
16
16
|
process.stderr.write(`[trantor] baton handoff written: ${file}\n`);
|
|
17
17
|
await pingBus(basename(projectDir), record.id, conf);
|
|
18
18
|
if (maybeSpawn(projectDir, conf)) { // open the fresh session that takes over
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// AUTO baton: close the original ONLY if explicitly opted in (config.autoCloseOriginal:true).
|
|
20
|
+
// Default = leave the original alive (the fresh window takes over; you close the old one). This is
|
|
21
|
+
// the 2026-06-21 fix — an auto-close must never kill an in-flight session.
|
|
22
|
+
const armed = windowId ? armBatonClose(file, windowId, tty, conf, { auto: true }) : false;
|
|
23
|
+
process.stderr.write(`[trantor] fresh session spawned${armed ? ` · baton-close armed for window ${windowId}` : " · original window left alive (auto-close off by default)"}\n`);
|
|
21
24
|
}
|
|
22
25
|
} catch (e) {
|
|
23
26
|
process.stderr.write(`[trantor] handoff-now error: ${e?.message || e}\n`);
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { join, basename, dirname } from "node:path";
|
|
|
18
18
|
import { homedir, hostname } from "node:os";
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { readConfig, contextUsage, warnFrac, alreadyHandedOff, markHandedOff, controllingTty, terminalWindowForTty } from "./lib/handoff.mjs";
|
|
21
|
+
import { readConfig, contextUsage, warnFrac, alreadyHandedOff, markHandedOff, controllingTty, terminalWindowForTty, subagentsActive } from "./lib/handoff.mjs";
|
|
22
22
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
23
23
|
|
|
24
24
|
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
@@ -49,6 +49,16 @@ async function maybeEarlyWarn(stdinRaw, session) {
|
|
|
49
49
|
if (usage.frac < warnFrac(conf)) return;
|
|
50
50
|
if (alreadyHandedOff(sessionId, usage.tokens)) return;
|
|
51
51
|
|
|
52
|
+
// Mid-build guard (incident 2026-06-21): never fire an auto baton-pass while this session is
|
|
53
|
+
// actively orchestrating sub-agents — popping a fresh window (or, before the fix, killing the
|
|
54
|
+
// original) mid 2-agent build is exactly the failure we must prevent. Defer: the next heartbeat
|
|
55
|
+
// re-checks once the agents finish, and PreCompact remains the at-the-wall backstop. We do NOT
|
|
56
|
+
// markHandedOff here, so the baton genuinely retries later instead of being silently skipped.
|
|
57
|
+
if (subagentsActive(transcript)) {
|
|
58
|
+
process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% but sub-agents active — deferring baton pass\n`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
// In-flight guard: the detached worker takes ~tens of seconds to summarize;
|
|
53
63
|
// don't launch a second one on the next heartbeat tick meanwhile.
|
|
54
64
|
const inflight = join(homedir(), ".agent-bus", `handoff-inflight-${String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "_")}.stamp`);
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
// session that loads the handoff. The heartbeat path lets us do that BEFORE the
|
|
11
11
|
// wall when we know the window size. Both paths share a per-session guard so we
|
|
12
12
|
// never write/spawn twice for the same context window.
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, openSync, readSync, fstatSync, closeSync } from "node:fs";
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, fstatSync, closeSync } from "node:fs";
|
|
14
14
|
import { join, basename, dirname } from "node:path";
|
|
15
15
|
import { homedir, hostname } from "node:os";
|
|
16
16
|
import { execSync, spawn } from "node:child_process";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
|
|
18
19
|
|
|
19
20
|
export const HANDOFF_DIR = join(process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus"), "handoffs");
|
|
20
21
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -105,6 +106,34 @@ export function markHandedOff(sessionId, curTokens = 0) {
|
|
|
105
106
|
|
|
106
107
|
function nowSec() { try { return Number(execSync("date +%s", { encoding: "utf8" }).trim()) || 0; } catch { return 0; } }
|
|
107
108
|
|
|
109
|
+
// ---- in-flight guard --------------------------------------------------------
|
|
110
|
+
// True when this session is actively orchestrating sub-agents (Agent/Task tool, Workflow swarms,
|
|
111
|
+
// agent-teams): any `agent-*.jsonl` under <transcriptDir>/<sid>/subagents/ (incl. workflows/) was
|
|
112
|
+
// written within `withinMs`. The auto baton-pass uses this to DEFER — we must never yank a fresh
|
|
113
|
+
// window up (or, before the 2026-06-21 fix, kill the original) while real in-flight agent work is
|
|
114
|
+
// running. INCIDENT 2026-06-21: a 90% baton fired mid 2-agent build and the original session was
|
|
115
|
+
// SIGKILLed mid-flight. Best-effort; returns false on any error.
|
|
116
|
+
export function subagentsActive(transcriptPath, withinMs = 90_000) {
|
|
117
|
+
try {
|
|
118
|
+
if (!transcriptPath) return false;
|
|
119
|
+
const sub = join(dirname(transcriptPath), basename(transcriptPath).replace(/\.jsonl$/i, ""), "subagents");
|
|
120
|
+
if (!existsSync(sub)) return false;
|
|
121
|
+
const cutoff = Date.now() - withinMs;
|
|
122
|
+
const stack = [sub];
|
|
123
|
+
while (stack.length) {
|
|
124
|
+
const d = stack.pop();
|
|
125
|
+
let entries; try { entries = readdirSync(d, { withFileTypes: true }); } catch { continue; }
|
|
126
|
+
for (const e of entries) {
|
|
127
|
+
const p = join(d, e.name);
|
|
128
|
+
if (e.isDirectory()) { stack.push(p); continue; }
|
|
129
|
+
if (!/^agent-.*\.jsonl$/i.test(e.name)) continue;
|
|
130
|
+
try { if (statSync(p).mtimeMs >= cutoff) return true; } catch {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
} catch { return false; }
|
|
135
|
+
}
|
|
136
|
+
|
|
108
137
|
// ---- whole-session summary --------------------------------------------------
|
|
109
138
|
function collectTurns(transcriptPath) {
|
|
110
139
|
const rows = readFileSync(transcriptPath, "utf8").split("\n").filter(Boolean)
|
|
@@ -192,6 +221,12 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
192
221
|
try { gitStatus = execSync("git -C " + JSON.stringify(projectDir) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
|
|
193
222
|
const narrative = summary ?? buildSummary(transcript);
|
|
194
223
|
const tail = verbatimRecentTail(transcript);
|
|
224
|
+
// Sub-agent manifest SNAPSHOT (fallback). The successor should re-derive it LIVE via
|
|
225
|
+
// `trantor agents <sid>` (catches files an agent finished that were clobbered AFTER this
|
|
226
|
+
// snapshot — the kill that motivated this corrupted a completed 30KB lib post-handoff). This
|
|
227
|
+
// baked copy is just orientation if the live command isn't available. Best-effort; never throws.
|
|
228
|
+
let subagents = null;
|
|
229
|
+
try { subagents = deriveSubagentManifest(transcript, { projectRoot: projectDir }); } catch {}
|
|
195
230
|
const record = {
|
|
196
231
|
id: `${projectName}-${stamp}`,
|
|
197
232
|
project: projectDir, projectName, machine: hostname(),
|
|
@@ -199,7 +234,7 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
199
234
|
transcript_path: transcript || "", stamp: Number(stamp) || 0,
|
|
200
235
|
// narrative + a verbatim recent-exchange block so exact in-flight state always survives
|
|
201
236
|
summary: narrative + (tail ? `\n\n---\n## Verbatim recent exchange (exact in-flight state — continue from here)\n${tail}` : ""),
|
|
202
|
-
gitStatus, consumed: false,
|
|
237
|
+
gitStatus, subagents, consumed: false,
|
|
203
238
|
};
|
|
204
239
|
const file = join(HANDOFF_DIR, `${record.id}.json`);
|
|
205
240
|
writeFileSync(file, JSON.stringify(record, null, 2));
|
|
@@ -262,10 +297,17 @@ export function terminalWindowForTty(tty) {
|
|
|
262
297
|
// Arm the baton-close watcher: a DETACHED process that waits until the fresh session consumes the
|
|
263
298
|
// handoff (consumed:true), then closes the original Terminal window. Never closes blind: aborts on
|
|
264
299
|
// timeout (fresh never showed) and re-validates the window's tty before closing.
|
|
265
|
-
export function armBatonClose(handoffFile, originalWindowId, originalTty, conf = readConfig()) {
|
|
300
|
+
export function armBatonClose(handoffFile, originalWindowId, originalTty, conf = readConfig(), { auto = false } = {}) {
|
|
266
301
|
try {
|
|
267
302
|
if (process.platform !== "darwin" || !originalWindowId) return false;
|
|
268
303
|
if (process.env.TRANTOR_NO_BATON_CLOSE === "1" || conf.batonClose === false) return false;
|
|
304
|
+
// SAFETY (incident 2026-06-21): an AUTOMATIC baton must NEVER close the original session. At 90%
|
|
305
|
+
// (10% headroom) mid 2-agent build, auto-close SIGKILLed the original window's processes and killed
|
|
306
|
+
// in-flight work — the scariest possible failure. Auto-close is now strictly opt-in
|
|
307
|
+
// (config.autoCloseOriginal:true). The default auto baton just opens the fresh window and LEAVES the
|
|
308
|
+
// original alive. Manual /trantor:handoff still closes (the user explicitly invoked a wrap-up) — and
|
|
309
|
+
// even that is now non-destructive (baton-close never SIGKILLs and aborts if the original is busy).
|
|
310
|
+
if (auto && conf.autoCloseOriginal !== true) return false;
|
|
269
311
|
const closer = join(HERE, "..", "..", "bin", "baton-close.mjs");
|
|
270
312
|
if (!existsSync(closer)) return false;
|
|
271
313
|
const child = spawn(process.execPath, [closer, handoffFile, String(originalWindowId), originalTty || ""], { detached: true, stdio: "ignore" });
|
package/hooks/precompact.mjs
CHANGED
|
@@ -37,11 +37,13 @@ try {
|
|
|
37
37
|
process.stderr.write(`[trantor] fresh session already spawned for this window — handoff refreshed only\n`);
|
|
38
38
|
} else if (maybeSpawn(projectDir, conf)) {
|
|
39
39
|
markHandedOff(sessionId, cur);
|
|
40
|
-
// baton pass:
|
|
40
|
+
// baton pass: at-the-wall fresh session. Close THIS window ONLY if opted in
|
|
41
|
+
// (config.autoCloseOriginal:true) — default leaves the original alive (2026-06-21 fix: an auto
|
|
42
|
+
// baton must never kill a session). We have the controlling tty here for the opt-in case.
|
|
41
43
|
const tty = controllingTty();
|
|
42
44
|
const windowId = tty ? terminalWindowForTty(tty) : "";
|
|
43
|
-
const armed = windowId ? armBatonClose(file, windowId, tty, conf) : false;
|
|
44
|
-
process.stderr.write(`[trantor] fresh-session spawned (PreCompact)${armed ? ` · baton-close armed for window ${windowId}` : ""}\n`);
|
|
45
|
+
const armed = windowId ? armBatonClose(file, windowId, tty, conf, { auto: true }) : false;
|
|
46
|
+
process.stderr.write(`[trantor] fresh-session spawned (PreCompact)${armed ? ` · baton-close armed for window ${windowId}` : " · original window left alive (auto-close off by default)"}\n`);
|
|
45
47
|
}
|
|
46
48
|
} catch (err) {
|
|
47
49
|
process.stderr.write(`[trantor] precompact error: ${err?.message || err}\n`);
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { join, basename } from "node:path";
|
|
|
11
11
|
import { homedir, hostname } from "node:os";
|
|
12
12
|
import { execSync } from "node:child_process";
|
|
13
13
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
14
|
+
import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
|
|
14
15
|
import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
|
|
15
16
|
|
|
16
17
|
// Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
|
|
@@ -198,6 +199,17 @@ try {
|
|
|
198
199
|
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`;
|
|
199
200
|
additionalContext += `## Handoff summary\n${sanitize(handoff.summary)}\n`;
|
|
200
201
|
if (handoff.gitStatus) additionalContext += `\n## Git working-tree at handoff\n\`\`\`\n${sanitize(handoff.gitStatus)}\n\`\`\`\n`;
|
|
202
|
+
// Sub-agent manifest: LIVE-primary, snapshot-as-fallback. The prior session may have had
|
|
203
|
+
// sub-agents (Agent/Task, Workflow) building things you can't see in its narrative — and a
|
|
204
|
+
// kill can corrupt an agent's finished file on disk. Direct the successor to re-derive LIVE
|
|
205
|
+
// (reconciles against current disk) and trust that over the baked snapshot.
|
|
206
|
+
if (handoff.subagents && handoff.subagents.counts && handoff.subagents.counts.total) {
|
|
207
|
+
const sa = handoff.subagents;
|
|
208
|
+
additionalContext += `\n## Sub-agents the prior session ran (${sa.counts.total}: ${sa.counts.completed} completed, ${sa.counts.inFlight} in-flight at handoff)\n`;
|
|
209
|
+
additionalContext += `**Before continuing, get the LIVE manifest** — run \`trantor agents ${sanitize(handoff.session_id)}\` (or \`trantor agents\` from this project). It re-derives from CURRENT disk, flagging any file an agent finished that was later clobbered — do NOT assume "nothing survived"; recover from the agent's transcript. Trust the live command over the snapshot below.\n`;
|
|
210
|
+
if (sa.counts.suspectFiles) additionalContext += `⚠️ ${sa.counts.suspectFiles} file(s) an agent wrote looked CLOBBERED at handoff time — verify with the live command and recover.\n`;
|
|
211
|
+
additionalContext += `\n\`\`\`\n${sanitize(formatSubagentManifest(sa, { heading: false }))}\n\`\`\`\n`;
|
|
212
|
+
}
|
|
201
213
|
if (handoff.transcript_path) additionalContext += `\n_Full prior transcript: ${sanitize(handoff.transcript_path)}_\n`;
|
|
202
214
|
additionalContext += `</trantor-handoff>\n`;
|
|
203
215
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// trantor sub-agent manifest — reconstruct what a session's sub-agents (Agent/Task tool,
|
|
2
|
+
// Workflow swarms, agent-teams) were doing, purely from on-disk transcripts. No new runtime
|
|
3
|
+
// instrumentation: it's a READ-TIME projection of primary sources, so it can't drift and the
|
|
4
|
+
// successor can re-derive it itself (that's the point — live-primary, snapshot-as-fallback).
|
|
5
|
+
//
|
|
6
|
+
// Born from the 2026-06-21 incident: an auto baton-pass SIGKILLed a session mid 2-agent build.
|
|
7
|
+
// The fresh session had no idea two agents were even running, and one agent's COMPLETED 30KB
|
|
8
|
+
// implementation had been clobbered on disk to a 17-byte stub by the kill — so the successor
|
|
9
|
+
// rebuilt it from scratch believing "nothing survived." The manifest surfaces exactly that:
|
|
10
|
+
// what each agent was tasked with, whether it returned, what it wrote, and — via a disk
|
|
11
|
+
// reconcile — whether the files it wrote still survive or look clobbered.
|
|
12
|
+
//
|
|
13
|
+
// Four primary sources (all already on disk under ~/.claude/projects/<proj>/<sid>/):
|
|
14
|
+
// 1. subagents/*.meta.json → {name, agentType, description (the task), toolUseId}
|
|
15
|
+
// 2. parent <sid>.jsonl → tool_result ids ⇒ which agents RETURNED (completed)
|
|
16
|
+
// 3. each subagents/agent-*.jsonl → files written, last activity, the result it reported
|
|
17
|
+
// 4. disk reconcile → agent wrote X@N bytes; does X still exist & match? (suspect)
|
|
18
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
19
|
+
import { join, dirname, basename, relative } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
|
|
22
|
+
const EDIT_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit)$/;
|
|
23
|
+
|
|
24
|
+
function parseLines(path) {
|
|
25
|
+
let raw; try { raw = readFileSync(path, "utf8"); } catch { return []; }
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const ln of raw.split("\n")) { if (!ln) continue; try { out.push(JSON.parse(ln)); } catch {} }
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Resolve a bare session id to its transcript path by scanning ~/.claude/projects/*/<sid>.jsonl.
|
|
32
|
+
export function resolveTranscriptForSid(sid) {
|
|
33
|
+
if (!sid) return "";
|
|
34
|
+
const base = join(homedir(), ".claude", "projects");
|
|
35
|
+
try {
|
|
36
|
+
for (const proj of readdirSync(base)) {
|
|
37
|
+
const p = join(base, proj, `${sid}.jsonl`);
|
|
38
|
+
if (existsSync(p)) return p;
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Walk subagents/ (recursing into workflows/<wf>/) collecting every *.meta.json paired with its
|
|
45
|
+
// .jsonl. `workflow` is the workflow id when the agent lives under workflows/<wf>/, else null.
|
|
46
|
+
function collectMetas(subdir) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const walk = (dir, workflow) => {
|
|
49
|
+
let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const p = join(dir, e.name);
|
|
52
|
+
if (e.isDirectory()) { walk(p, e.name === "workflows" ? workflow : e.name); continue; }
|
|
53
|
+
if (!e.name.endsWith(".meta.json")) continue;
|
|
54
|
+
let meta; try { meta = JSON.parse(readFileSync(p, "utf8")); } catch { continue; }
|
|
55
|
+
out.push({ ...meta, jsonlPath: p.replace(/\.meta\.json$/, ".jsonl"), workflow: workflow || null });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
walk(subdir, null);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shortPath(absPath, projectRoot) {
|
|
63
|
+
try {
|
|
64
|
+
if (projectRoot && absPath.startsWith(projectRoot)) return relative(projectRoot, absPath) || absPath;
|
|
65
|
+
} catch {}
|
|
66
|
+
return absPath.split("/").slice(-4).join("/");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Does the file the agent wrote still survive intact? agentBytes = the size the agent last wrote.
|
|
70
|
+
// suspect = the file is gone, or shrank far below what the agent wrote (clobbered, e.g. by a kill).
|
|
71
|
+
function reconcileFile(absPath, agentBytes, projectRoot) {
|
|
72
|
+
let onDiskNow = null, suspect = false;
|
|
73
|
+
try {
|
|
74
|
+
if (existsSync(absPath)) {
|
|
75
|
+
onDiskNow = statSync(absPath).size;
|
|
76
|
+
if (agentBytes > 200 && onDiskNow < Math.min(agentBytes * 0.5, agentBytes - 200)) suspect = true;
|
|
77
|
+
} else {
|
|
78
|
+
onDiskNow = 0;
|
|
79
|
+
suspect = agentBytes > 200; // the agent wrote real content but the file is gone
|
|
80
|
+
}
|
|
81
|
+
} catch {}
|
|
82
|
+
return { path: shortPath(absPath, projectRoot), agentBytes, onDiskNow, suspect };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function analyzeAgent(meta, completedIds, projectRoot) {
|
|
86
|
+
const wrote = new Map(); // absPath → last-write byte length
|
|
87
|
+
let lastMs = 0, result = "";
|
|
88
|
+
for (const r of parseLines(meta.jsonlPath)) {
|
|
89
|
+
const ts = r.timestamp ? Date.parse(r.timestamp) || 0 : 0;
|
|
90
|
+
if (ts > lastMs) lastMs = ts;
|
|
91
|
+
const c = r?.message?.content;
|
|
92
|
+
if (!Array.isArray(c)) continue;
|
|
93
|
+
for (const b of c) {
|
|
94
|
+
if (b?.type === "tool_use" && EDIT_TOOLS.test(b.name) && b.input?.file_path) {
|
|
95
|
+
const content = b.input.content ?? b.input.new_string ?? "";
|
|
96
|
+
wrote.set(b.input.file_path, content.length); // last write to a path wins
|
|
97
|
+
}
|
|
98
|
+
if (b?.type === "text" && typeof b.text === "string" && b.text.trim()) result = b.text.trim();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const status = meta.toolUseId && completedIds.has(meta.toolUseId) ? "completed" : "in-flight";
|
|
102
|
+
return {
|
|
103
|
+
name: meta.name || basename(meta.jsonlPath).replace(/\.jsonl$/, ""),
|
|
104
|
+
agentType: meta.agentType || "",
|
|
105
|
+
task: meta.description || "",
|
|
106
|
+
workflow: meta.workflow || null,
|
|
107
|
+
status,
|
|
108
|
+
wrote: [...wrote.entries()].map(([p, n]) => reconcileFile(p, n, projectRoot)),
|
|
109
|
+
lastActivity: lastMs ? new Date(lastMs).toISOString() : null,
|
|
110
|
+
lastActivityMs: lastMs,
|
|
111
|
+
transcript: meta.jsonlPath,
|
|
112
|
+
result: result.slice(0, 400),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Derive the full manifest for a session from its parent transcript path. projectRoot (the repo
|
|
117
|
+
// dir) is used only to shorten displayed file paths. Returns { sessionId, subagents[], counts }.
|
|
118
|
+
export function deriveSubagentManifest(parentTranscript, { projectRoot } = {}) {
|
|
119
|
+
const out = { sessionId: "", subagents: [], counts: { total: 0, completed: 0, inFlight: 0, suspectFiles: 0 } };
|
|
120
|
+
try {
|
|
121
|
+
if (!parentTranscript || !existsSync(parentTranscript)) return out;
|
|
122
|
+
const sid = basename(parentTranscript).replace(/\.jsonl$/i, "");
|
|
123
|
+
out.sessionId = sid;
|
|
124
|
+
const subdir = join(dirname(parentTranscript), sid, "subagents");
|
|
125
|
+
if (!existsSync(subdir)) return out;
|
|
126
|
+
|
|
127
|
+
// (2) parent transcript → tool_result ids = agents that returned a result.
|
|
128
|
+
const completedIds = new Set();
|
|
129
|
+
for (const r of parseLines(parentTranscript)) {
|
|
130
|
+
const c = r?.message?.content;
|
|
131
|
+
if (Array.isArray(c)) for (const b of c) if (b?.type === "tool_result" && b.tool_use_id) completedIds.add(b.tool_use_id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const metas = collectMetas(subdir);
|
|
135
|
+
out.subagents = metas.map((m) => analyzeAgent(m, completedIds, projectRoot))
|
|
136
|
+
.sort((a, b) => (a.lastActivityMs || 0) - (b.lastActivityMs || 0));
|
|
137
|
+
|
|
138
|
+
out.counts.total = out.subagents.length;
|
|
139
|
+
out.counts.completed = out.subagents.filter((s) => s.status === "completed").length;
|
|
140
|
+
out.counts.inFlight = out.subagents.filter((s) => s.status === "in-flight").length;
|
|
141
|
+
out.counts.suspectFiles = out.subagents.reduce((n, s) => n + s.wrote.filter((w) => w.suspect).length, 0);
|
|
142
|
+
} catch {}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Human-readable rendering (the `trantor agents` output, and the handoff snapshot block).
|
|
147
|
+
export function formatSubagentManifest(m, { heading = true } = {}) {
|
|
148
|
+
if (!m || !m.subagents.length) return "No sub-agents found for this session.";
|
|
149
|
+
const L = [];
|
|
150
|
+
if (heading) {
|
|
151
|
+
L.push(`Sub-agent manifest — ${m.counts.total} agents (${m.counts.completed} completed, ${m.counts.inFlight} in-flight at handoff)`);
|
|
152
|
+
if (m.counts.suspectFiles) {
|
|
153
|
+
L.push(`⚠️ ${m.counts.suspectFiles} file(s) an agent wrote look CLOBBERED on disk (gone or far smaller than written) — RECOVER from the agent's transcript before assuming the work was never done.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const s of m.subagents) {
|
|
157
|
+
const badge = s.status === "completed" ? "✅ completed" : "🛑 IN-FLIGHT at handoff";
|
|
158
|
+
L.push("");
|
|
159
|
+
L.push(`• ${s.name} [${s.agentType}]${s.workflow ? ` · wf:${s.workflow}` : ""} ${badge}`);
|
|
160
|
+
if (s.task) L.push(` task: ${s.task}`);
|
|
161
|
+
if (s.wrote.length) {
|
|
162
|
+
for (const w of s.wrote) {
|
|
163
|
+
const size = w.onDiskNow != null ? (w.onDiskNow === 0 && w.suspect ? "MISSING" : `${w.onDiskNow}B on disk`) : "?";
|
|
164
|
+
const flag = w.suspect ? ` ⚠️ SUSPECT — agent wrote ${w.agentBytes}B, recover from transcript` : "";
|
|
165
|
+
L.push(` wrote: ${w.path} (${size})${flag}`);
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
L.push(` wrote: (no file edits)`);
|
|
169
|
+
}
|
|
170
|
+
L.push(` transcript: ${s.transcript}`);
|
|
171
|
+
}
|
|
172
|
+
L.push("");
|
|
173
|
+
L.push(`Read any agent's transcript for its full reasoning/sourcing before trusting OR discarding its work.`);
|
|
174
|
+
return L.join("\n");
|
|
175
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.31",
|
|
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 && node test-handoff.mjs && node test-update.mjs"
|
|
13
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs"
|
|
14
14
|
},
|
|
15
15
|
"description": "The hub-world for AI agent crews — 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": [
|