trantor 0.17.21 → 0.17.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + context-handoff for independent AI coding agents (Claude, Codex, Gemini, …)",
9
- "version": "0.17.21"
9
+ "version": "0.17.23"
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.21",
16
+ "version": "0.17.23",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.21",
3
+ "version": "0.17.23",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // trantor baton-close — the second half of the baton pass. Runs DETACHED, armed by the handoff hook.
3
+ // Waits until the FRESH session has consumed the handoff (consumed:true on the handoff file = it
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. Defensive: aborts
6
+ // if the fresh session never shows (timeout), and re-validates the window's tty before closing so it can
7
+ // NEVER close the wrong window. Args: <handoffFile> <originalWindowId> <originalTty>
8
+ import { readFileSync, existsSync } from "node:fs";
9
+ import { execSync } from "node:child_process";
10
+
11
+ const [, , handoffFile, windowId, originalTty] = process.argv;
12
+ const POLL_MS = 1500, TIMEOUT_MS = 120_000;
13
+
14
+ const consumed = () => { try { return JSON.parse(readFileSync(handoffFile, "utf8")).consumed === true; } catch { return false; } };
15
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
16
+
17
+ function ttyOfWindow(id) {
18
+ try {
19
+ return execSync(`osascript -e ${JSON.stringify(`tell application "Terminal" to get tty of selected tab of (first window whose id is ${id})`)}`,
20
+ { encoding: "utf8", timeout: 3000 }).trim();
21
+ } catch { return ""; }
22
+ }
23
+ function closeWindow(id, tty) {
24
+ // re-validate: only close if the window is STILL on the original tty (never close a re-used/wrong window)
25
+ const cur = ttyOfWindow(id);
26
+ if (!cur || (originalTty && cur !== originalTty)) {
27
+ process.stderr.write(`[trantor] baton-close: window ${id} tty changed (${cur} != ${originalTty}) — NOT closing\n`);
28
+ return false;
29
+ }
30
+ try {
31
+ // SIGKILL the processes on that tty first (claude traps SIGTERM; a live login makes close() pop a dialog)
32
+ const dev = (tty || cur).replace(/^\/dev\//, "");
33
+ for (const pid of execSync(`ps -t ${dev} -o pid= 2>/dev/null || true`, { encoding: "utf8" }).trim().split("\n").filter(Boolean)) {
34
+ try { execSync(`kill -9 ${pid.trim()} 2>/dev/null || true`); } catch {}
35
+ }
36
+ } catch {}
37
+ try { execSync(`osascript -e ${JSON.stringify(`tell application "Terminal" to close (first window whose id is ${id})`)}`, { timeout: 3000 }); return true; } catch { return false; }
38
+ }
39
+
40
+ (async () => {
41
+ if (!handoffFile || !windowId) process.exit(0);
42
+ const deadline = Date.now() + TIMEOUT_MS;
43
+ while (Date.now() < deadline) {
44
+ if (consumed()) {
45
+ await sleep(2500); // let the fresh session settle (register, inject) before we pull the original
46
+ const ok = closeWindow(windowId, originalTty);
47
+ process.stderr.write(`[trantor] baton-close: fresh session took over → original window ${windowId} ${ok ? "closed" : "left (validation/close failed)"}\n`);
48
+ process.exit(0);
49
+ }
50
+ await sleep(POLL_MS);
51
+ }
52
+ process.stderr.write(`[trantor] baton-close: fresh session never confirmed within ${TIMEOUT_MS / 1000}s — leaving the original alive (safe)\n`);
53
+ process.exit(0);
54
+ })();
package/bin/baton.mjs ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // `trantor handoff` — one-command manual baton (auto-summary variant). Discovers the current session's
3
+ // transcript, writes a whole-session handoff (auto-summary + verbatim in-flight tail), opens a fresh
4
+ // self-announcing session, and closes THIS window once it takes over. Run from inside the session you
5
+ // want to hand off. (The richer MODEL-authored handoff is the /trantor:handoff skill.)
6
+ import { readdirSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { resolveProject } from "../lib/project.mjs";
10
+ import { writeHandoff, spawnBaton } from "../hooks/lib/handoff.mjs";
11
+
12
+ const cwd = process.cwd();
13
+ const project = resolveProject(cwd);
14
+
15
+ // The active session's transcript = newest *.jsonl directly in this project's Claude dir
16
+ // (~/.claude/projects/<cwd-with-slashes-as-dashes>/), excluding the subagents/ subtree.
17
+ function findTranscript() {
18
+ const dashed = cwd.replace(/\//g, "-");
19
+ const base = join(homedir(), ".claude", "projects");
20
+ let best = "", bestM = 0;
21
+ let dirs = []; try { dirs = readdirSync(base).filter(d => d === dashed || d.endsWith(dashed)); } catch {}
22
+ for (const d of dirs) {
23
+ let ents = []; try { ents = readdirSync(join(base, d)); } catch {}
24
+ for (const f of ents) {
25
+ if (!f.endsWith(".jsonl")) continue;
26
+ try { const m = statSync(join(base, d, f)).mtimeMs; if (m > bestM) { best = join(base, d, f); bestM = m; } } catch {}
27
+ }
28
+ }
29
+ return best;
30
+ }
31
+
32
+ const transcript = findTranscript();
33
+ const { file } = writeHandoff({ projectDir: cwd, sessionId: "", transcript, trigger: "manual-cli" });
34
+ console.log(`📋 handoff saved for ${project}: ${file}`);
35
+ const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
36
+ console.log(spawned
37
+ ? `🔄 baton: a fresh session is opening (it'll recap the handoff)${armed ? ` — this window (${windowId}) closes once it takes over` : " — couldn't detect this window; close it yourself once the new one is up"}`
38
+ : `handoff saved, but couldn't spawn a fresh session (non-macOS or spawn disabled) — open a new session here to take over`);
package/bin/cli.mjs CHANGED
@@ -27,6 +27,7 @@ switch (cmd) {
27
27
  case "watch": run("bin/relay-watch.mjs"); break;
28
28
  case "catchup": run("bin/catchup.mjs"); break;
29
29
  case "backfill": run("bin/git-backfill.mjs"); break;
30
+ case "handoff": run("bin/baton.mjs"); break;
30
31
  case "ui": {
31
32
  let url = "http://127.0.0.1:4477";
32
33
  try { url = JSON.parse(readFileSync(join(process.env.HOME || "", ".agent-bus", "config.json"), "utf8")).url || url; } catch {}
@@ -48,6 +49,7 @@ switch (cmd) {
48
49
  trantor ui open the live dashboard (board + flow views)
49
50
  trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
50
51
  trantor backfill card past GIT work onto the board (solo commits that were never carded) — [--since "14 days ago"] [--dry-run]
52
+ trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
51
53
  trantor advise ask the Advisor directly (JSON on stdin; --demo to see it)
52
54
  trantor hub run the hub in the foreground (setup installs it as a service instead)
53
55
  trantor watch live bus feed in the terminal
@@ -7,15 +7,17 @@
7
7
  # Agent to spawn = $AGENT_CMD (default "claude") — same agent, fresh window.
8
8
  DIR="${1:-$HOME}"
9
9
  TIMEOUT="${2:-25}"
10
- AGENT_CMD="${AGENT_CMD:-claude}"
10
+ # The fresh session SELF-ANNOUNCES: it opens already recapping the handoff it took over, so it's never
11
+ # a confusing empty prompt. Single-quoted so it survives osascript->shell with no escaping (no apostrophes).
12
+ AGENT_CMD="${AGENT_CMD:-claude 'Recap the handoff you just took over — what was the previous session doing, and where do we continue? Then wait for me.'}"
11
13
  HERE="$(cd "$(dirname "$0")" && pwd)"
12
14
  NAME="$(basename "$DIR")"
13
15
 
14
- MSG="trantor — this session's context window is full ($NAME). Open a FRESH session to take over with a full window? It loads a handoff of this session. (The current session keeps compacting either way.)"
16
+ MSG="trantor — context is at ~90% on $NAME. Hand off to a FRESH full-window session now? It loads this session's handoff and continues; this window then closes (baton pass). Cancel to keep working here."
15
17
 
16
18
  # Best-effort timed dialog. On timeout, error, or no UI session -> empty -> we spawn (the default).
17
- CHOICE="$(osascript -e "button returned of (display dialog \"${MSG//\"/\\\"}\" buttons {\"Keep compacting\", \"Open fresh session\"} default button \"Open fresh session\" giving up after $TIMEOUT with title \"trantor\")" 2>/dev/null)"
19
+ CHOICE="$(osascript -e "button returned of (display dialog \"${MSG//\"/\\\"}\" buttons {\"Keep working here\", \"Hand off\"} default button \"Hand off\" giving up after $TIMEOUT with title \"trantor\")" 2>/dev/null)"
18
20
 
19
- if [ "$CHOICE" != "Keep compacting" ]; then
21
+ if [ "$CHOICE" != "Keep working here" ]; then
20
22
  "$HERE/open-session.sh" "$DIR" "$AGENT_CMD"
21
23
  fi
@@ -1,17 +1,29 @@
1
1
  #!/usr/bin/env node
2
- import { writeFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ // Save a model-authored handoff (piped on stdin) for this project; the next session auto-loads it.
3
+ // With --baton: ALSO open a fresh self-announcing session and close THIS window once it takes over
4
+ // (the one-command manual baton behind /trantor:handoff). Without it: just write the file (legacy).
5
+ import { writeFileSync, existsSync, mkdirSync } from "node:fs";
3
6
  import { join, basename } from "node:path";
4
7
  import { homedir, hostname } from "node:os";
5
8
  import { execSync } from "node:child_process";
9
+ import { spawnBaton } from "../hooks/lib/handoff.mjs";
10
+
11
+ const baton = process.argv.includes("--baton");
6
12
  const project = process.env.CLAUDE_PROJECT_DIR || process.cwd();
7
13
  const name = basename(project);
8
14
  let summary = ""; process.stdin.setEncoding("utf8");
9
15
  for await (const c of process.stdin) summary += c;
10
16
  const dir = join(homedir(), ".agent-bus", "handoffs");
11
17
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
12
- const stamp = (() => { try { return execSync("date +%s",{encoding:"utf8"}).trim(); } catch { return String(process.pid); } })();
13
- let git=""; try { git = execSync("git -C "+JSON.stringify(project)+" status --short 2>/dev/null | head -30",{encoding:"utf8"}).trim(); } catch {}
14
- const rec = { id:`${name}-${stamp}`, project, projectName:name, machine:hostname(), trigger:"manual-skill", stamp:Number(stamp)||0, summary:summary.trim()||"(empty)", gitStatus:git, consumed:false };
18
+ const stamp = (() => { try { return execSync("date +%s", { encoding: "utf8" }).trim(); } catch { return String(process.pid); } })();
19
+ let git = ""; try { git = execSync("git -C " + JSON.stringify(project) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
20
+ const rec = { id: `${name}-${stamp}`, project, projectName: name, machine: hostname(), trigger: baton ? "manual-baton" : "manual-skill", stamp: Number(stamp) || 0, summary: summary.trim() || "(empty)", gitStatus: git, consumed: false };
15
21
  const file = join(dir, `${rec.id}.json`);
16
- writeFileSync(file, JSON.stringify(rec,null,2));
22
+ writeFileSync(file, JSON.stringify(rec, null, 2));
17
23
  console.log(`handoff saved: ${file}`);
24
+
25
+ if (baton) {
26
+ const { spawned, armed, windowId } = spawnBaton({ projectDir: project, handoffFile: file });
27
+ if (spawned) console.log(`baton: fresh session opening (self-recapping)${armed ? ` — this window (${windowId}) closes once it takes over` : " — original window left open (couldn't detect it)"}`);
28
+ else console.log(`baton: could not spawn a fresh session (non-macOS or spawn disabled) — handoff saved, open a new session manually`);
29
+ }
@@ -1,20 +1,24 @@
1
1
  #!/usr/bin/env node
2
- // trantor — detached EARLY-WARNING handoff worker. The PostToolUse heartbeat spawns this when a
3
- // session crosses its context warn threshold (~85% of a known window). Its job is to PREPARE a
4
- // safety-net handoff and NOTIFY NOT to open a window. Spawning a fresh session while the original
5
- // is still perfectly usable created a surprise pop-up + an orphaned duplicate session; the actual
6
- // fresh-window spawn now happens only AT THE WALL (PreCompact). The (~60s) scrooge summary runs here,
7
- // detached, so it never blocks a tool call. Args: <projectDir> <sessionId> <transcriptPath> [trigger]
8
- import { readConfig, writeHandoff, pingBus, contextUsage } from "./lib/handoff.mjs";
2
+ // trantor — detached BATON-PASS worker. The PostToolUse heartbeat spawns this when a session crosses
3
+ // its context warn threshold (~90% of a known window). It writes a whole-session handoff (narrative +
4
+ // verbatim in-flight tail), spawns a FRESH session to take over, and arms the baton-close watcher which
5
+ // once the fresh session has consumed the handoff closes THIS (original) session's Terminal window.
6
+ // The heavy scrooge summary runs here, detached, so it never blocks a tool call. The original's window
7
+ // id + tty are detected by the heartbeat (which has the controlling tty) and passed in as args.
8
+ // Args: <projectDir> <sessionId> <transcriptPath> [trigger] [originalWindowId] [originalTty]
9
+ import { readConfig, writeHandoff, pingBus, maybeSpawn, armBatonClose } from "./lib/handoff.mjs";
9
10
  import { basename } from "node:path";
10
11
 
11
- const [, , projectDir = process.cwd(), sessionId = "", transcript = "", trigger = "context-warn"] = process.argv;
12
+ const [, , projectDir = process.cwd(), sessionId = "", transcript = "", trigger = "context-warn", windowId = "", tty = ""] = process.argv;
12
13
  try {
13
14
  const conf = readConfig();
14
15
  const { file, record } = writeHandoff({ projectDir, sessionId, transcript, trigger });
15
- process.stderr.write(`[trantor] early handoff written (safety net, no spawn): ${file}\n`);
16
- // notify the bus so a watcher knows a handoff is ready — but do NOT spawn a window here.
16
+ process.stderr.write(`[trantor] baton handoff written: ${file}\n`);
17
17
  await pingBus(basename(projectDir), record.id, conf);
18
+ if (maybeSpawn(projectDir, conf)) { // open the fresh session that takes over
19
+ const armed = windowId ? armBatonClose(file, windowId, tty, conf) : false; // close the original once fresh confirms
20
+ process.stderr.write(`[trantor] fresh session spawned${armed ? ` · baton-close armed for window ${windowId}` : " · no original-window close (none detected / disabled)"}\n`);
21
+ }
18
22
  } catch (e) {
19
23
  process.stderr.write(`[trantor] handoff-now error: ${e?.message || e}\n`);
20
24
  }
@@ -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 } from "./lib/handoff.mjs";
21
+ import { readConfig, contextUsage, warnFrac, alreadyHandedOff, controllingTty, terminalWindowForTty } 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);
@@ -56,8 +56,12 @@ async function maybeEarlyWarn(stdinRaw, session) {
56
56
  try { writeFileSync(inflight, String(Date.now())); } catch {}
57
57
 
58
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"],
59
+ // Detect THIS session's Terminal window NOW (the hook has the controlling tty; the detached worker
60
+ // won't) so the baton-close can replace this exact window once the fresh session takes over.
61
+ const tty = controllingTty();
62
+ const windowId = tty ? terminalWindowForTty(tty) : "";
63
+ process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% of ${usage.window} — baton pass (window ${windowId || "?"})\n`);
64
+ const child = spawn(process.execPath, [join(HERE, "handoff-now.mjs"), projectDir, sessionId, transcript, "context-warn", windowId, tty],
61
65
  { detached: true, stdio: "ignore" });
62
66
  child.unref();
63
67
  } catch {}
@@ -75,8 +75,8 @@ export function resolveWindow(model = "", conf = readConfig()) {
75
75
  }
76
76
 
77
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;
78
+ const f = Number(process.env.RELAY_CONTEXT_WARN_FRAC || conf.contextWarnFrac || 0.90);
79
+ return f > 0 && f < 1 ? f : 0.90; // baton pass fires at 90% — runway to summarize + hand off before the wall
80
80
  }
81
81
 
82
82
  // ---- per-session guard (shared by both paths) -------------------------------
@@ -176,6 +176,13 @@ export function buildSummary(transcriptPath) {
176
176
  return `*(no summarizer available — representative transcript digest)*\n\n${convo.slice(-12000)}`;
177
177
  }
178
178
 
179
+ // The exact recent exchange, VERBATIM (not summarized/sampled) — so a baton-pass handoff carries the
180
+ // precise in-flight state (e.g. the live cs_live_… URL, the exact decision point) even if the scrooge
181
+ // narrative times out on a huge transcript. This is what lets the fresh session truly continue, not guess.
182
+ export function verbatimRecentTail(transcript, chars = 7000) {
183
+ try { return collectTurns(transcript).join("\n\n").slice(-chars); } catch { return ""; }
184
+ }
185
+
179
186
  // ---- write + announce + spawn ----------------------------------------------
180
187
  export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary }) {
181
188
  const projectName = basename(projectDir);
@@ -183,12 +190,15 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
183
190
  const stamp = nowSec() || Date.now();
184
191
  let gitStatus = "";
185
192
  try { gitStatus = execSync("git -C " + JSON.stringify(projectDir) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
193
+ const narrative = summary ?? buildSummary(transcript);
194
+ const tail = verbatimRecentTail(transcript);
186
195
  const record = {
187
196
  id: `${projectName}-${stamp}`,
188
197
  project: projectDir, projectName, machine: hostname(),
189
198
  session_id: sessionId || "", trigger: trigger || "auto",
190
199
  transcript_path: transcript || "", stamp: Number(stamp) || 0,
191
- summary: summary ?? buildSummary(transcript),
200
+ // narrative + a verbatim recent-exchange block so exact in-flight state always survives
201
+ summary: narrative + (tail ? `\n\n---\n## Verbatim recent exchange (exact in-flight state — continue from here)\n${tail}` : ""),
192
202
  gitStatus, consumed: false,
193
203
  };
194
204
  const file = join(HANDOFF_DIR, `${record.id}.json`);
@@ -196,6 +206,47 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
196
206
  return { file, record };
197
207
  }
198
208
 
209
+ // --- baton pass: the original session's Terminal window (macOS), so the fresh session can replace it ---
210
+ // Walk the process tree to the controlling tty (the hook itself may show "??" but its parent claude
211
+ // owns the Terminal's tty). Returns "/dev/ttysNNN" or "".
212
+ export function controllingTty() {
213
+ for (const pid of [process.pid, process.ppid, getPpid(process.ppid)]) {
214
+ if (!pid) continue;
215
+ try { const t = execSync(`ps -o tty= -p ${pid}`, { encoding: "utf8" }).trim(); if (t && t !== "??" && t !== "?") return "/dev/" + t; } catch {}
216
+ }
217
+ return "";
218
+ }
219
+ function getPpid(pid) { if (!pid) return 0; try { return Number(execSync(`ps -o ppid= -p ${pid}`, { encoding: "utf8" }).trim()) || 0; } catch { return 0; } }
220
+
221
+ // Find the Terminal.app window id whose selected tab is on `tty` — the window to close on takeover.
222
+ export function terminalWindowForTty(tty) {
223
+ if (process.platform !== "darwin" || !tty) return "";
224
+ const osa = `tell application "Terminal"
225
+ repeat with w in windows
226
+ try
227
+ if (tty of selected tab of w) is "${tty}" then return (id of w) as string
228
+ end try
229
+ end repeat
230
+ return ""
231
+ end tell`;
232
+ try { return execSync(`osascript -e ${JSON.stringify(osa)}`, { encoding: "utf8", timeout: 3000 }).trim(); } catch { return ""; }
233
+ }
234
+
235
+ // Arm the baton-close watcher: a DETACHED process that waits until the fresh session consumes the
236
+ // handoff (consumed:true), then closes the original Terminal window. Never closes blind: aborts on
237
+ // timeout (fresh never showed) and re-validates the window's tty before closing.
238
+ export function armBatonClose(handoffFile, originalWindowId, originalTty, conf = readConfig()) {
239
+ try {
240
+ if (process.platform !== "darwin" || !originalWindowId) return false;
241
+ if (process.env.TRANTOR_NO_BATON_CLOSE === "1" || conf.batonClose === false) return false;
242
+ const closer = join(HERE, "..", "..", "bin", "baton-close.mjs");
243
+ if (!existsSync(closer)) return false;
244
+ const child = spawn(process.execPath, [closer, handoffFile, String(originalWindowId), originalTty || ""], { detached: true, stdio: "ignore" });
245
+ child.unref();
246
+ return true;
247
+ } catch { return false; }
248
+ }
249
+
199
250
  export async function pingBus(projectName, id, conf = readConfig()) {
200
251
  try {
201
252
  await fetch(`${relayUrl(conf)}/send`, {
@@ -223,3 +274,41 @@ export function maybeSpawn(projectDir, conf = readConfig()) {
223
274
  return true;
224
275
  } catch (e) { process.stderr.write(`[trantor] maybeSpawn error: ${e?.message}\n`); return false; }
225
276
  }
277
+
278
+ // The self-announcing fresh session command (single-quoted so it survives osascript→shell un-escaped).
279
+ export const RECAP_CMD = "claude 'Recap the handoff you just took over — what was the previous session doing, and where do we continue? Then wait for me.'";
280
+
281
+ // Spawn a fresh self-announcing session WITHOUT the dialog (manual handoff — the user already decided).
282
+ export function spawnFresh(projectDir) {
283
+ try {
284
+ if (process.platform !== "darwin" || process.env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
285
+ const script = join(HERE, "..", "..", "bin", "open-session.sh");
286
+ if (!existsSync(script)) return false;
287
+ const child = spawn("/bin/bash", [script, projectDir, RECAP_CMD], { detached: true, stdio: "ignore" });
288
+ child.unref();
289
+ return true;
290
+ } catch { return false; }
291
+ }
292
+
293
+ // Terminal.app's front window (id + tty) — the fallback when there's no controlling tty (a manual
294
+ // handoff runs through the headless Bash tool). The session you're looking at when you invoke it.
295
+ export function frontTerminalWindow() {
296
+ if (process.platform !== "darwin") return { id: "", tty: "" };
297
+ try {
298
+ const out = execSync(`osascript -e ${JSON.stringify(`tell application "Terminal" to return (id of front window as string) & "|" & (tty of selected tab of front window)`)}`,
299
+ { encoding: "utf8", timeout: 3000 }).trim();
300
+ const [id, tty] = out.split("|"); return { id: id || "", tty: tty || "" };
301
+ } catch { return { id: "", tty: "" }; }
302
+ }
303
+
304
+ // MANUAL one-command baton: spawn the fresh session (no dialog) + arm the close of THIS window once the
305
+ // fresh one consumes the handoff. Window detection: controlling tty first (if invoked with one), else
306
+ // Terminal's front window. Returns { spawned, armed }.
307
+ export function spawnBaton({ projectDir, handoffFile, conf = readConfig() }) {
308
+ const spawned = spawnFresh(projectDir);
309
+ if (!spawned) return { spawned: false, armed: false };
310
+ let tty = controllingTty(), windowId = tty ? terminalWindowForTty(tty) : "";
311
+ if (!windowId) { const f = frontTerminalWindow(); windowId = f.id; tty = f.tty; }
312
+ const armed = windowId ? armBatonClose(handoffFile, windowId, tty, conf) : false;
313
+ return { spawned, armed, windowId };
314
+ }
@@ -6,8 +6,8 @@
6
6
  // full window. The new session's SessionStart hook loads the handoff. This is the
7
7
  // at-the-wall backstop; the heartbeat hook can also fire this earlier when the
8
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";
9
+ import { readConfig, writeHandoff, pingBus, maybeSpawn, armBatonClose,
10
+ contextUsage, alreadyHandedOff, markHandedOff, controllingTty, terminalWindowForTty } from "./lib/handoff.mjs";
11
11
  import { basename } from "node:path";
12
12
 
13
13
  function readStdin() {
@@ -37,7 +37,11 @@ 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
- process.stderr.write(`[trantor] fresh-session prompt launched (PreCompact)\n`);
40
+ // baton pass: arm the close of THIS window once the fresh session takes over (we have the tty here)
41
+ const tty = controllingTty();
42
+ 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`);
41
45
  }
42
46
  } catch (err) {
43
47
  process.stderr.write(`[trantor] precompact error: ${err?.message || err}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.21",
3
+ "version": "0.17.23",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -1,9 +1,10 @@
1
1
  ---
2
2
  name: handoff
3
3
  description: |
4
- Write a rich handoff for the CURRENT session so a fresh Claude Code session can take over
5
- with a full new context window (instead of compacting). Use proactively when context is
6
- getting full, or before ending, to pass the baton cleanly. Trigger: /trantor:handoff
4
+ Finish the CURRENT session in one move: write a rich model-authored handoff, open a fresh
5
+ full-window session that takes over (it auto-recaps the handoff), and close this one once the
6
+ fresh session has it a clean baton pass, one session at a time. Use when you want to wrap up
7
+ and continue fresh on demand (not just at the compaction threshold). Trigger: /trantor:handoff
7
8
  user-invocable: true
8
9
  ---
9
10
 
@@ -23,14 +24,17 @@ re-deriving context, and save it so the next session in this project auto-loads
23
24
  - **KEY FILES & LOCATIONS** — exact paths, commands, URLs, IDs the successor needs
24
25
  - **GOTCHAS** — anything that will bite if forgotten
25
26
 
26
- 2. Save it by piping the markdown to the helper:
27
+ 2. Save it AND pass the baton in one shot — pipe the markdown to the helper with `--baton`:
27
28
  ```bash
28
- cat << 'HANDOFF' | node "$(dirname "$(command -v claude)")/../<plugin>/bin/write-handoff.mjs"
29
+ cat << 'HANDOFF' | node "${CLAUDE_PLUGIN_ROOT}/bin/write-handoff.mjs" --baton
29
30
  <your handoff markdown>
30
31
  HANDOFF
31
32
  ```
32
- (Or call the plugin's `bin/write-handoff.mjs` directly via its `${CLAUDE_PLUGIN_ROOT}`.)
33
+ `--baton` writes the handoff, opens a FRESH session that takes over (it auto-recaps the handoff
34
+ on open), and closes THIS Terminal window once the fresh session has consumed it — a true baton
35
+ pass, one session at a time. (Omit `--baton` to only write the handoff without spawning/closing.)
36
+ It's safe: the original window is closed ONLY after the fresh session confirms it took over, and
37
+ never if the fresh session fails to start.
33
38
 
34
- 3. Tell the user: open a fresh terminal + `claude` in this same project directory — the
35
- SessionStart hook will detect the handoff and the new session takes over with a full window.
36
- (The PreCompact hook also writes one automatically at the compaction threshold.)
39
+ 3. Tell the user briefly: "Handoff written — a fresh session is opening and will recap it; this
40
+ window closes once it takes over." Then stop (the baton will close this session shortly).