trantor 0.17.29 → 0.17.30

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.29",
3
+ "version": "0.17.30",
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": {
@@ -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. 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";
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
- // SIGKILL the processes on that tty first (claude traps SIGTERM; a live login makes close() pop a dialog)
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 -9 ${pid.trim()} 2>/dev/null || true`); } catch {}
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);
@@ -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
- 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`);
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`);
@@ -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`);
@@ -10,7 +10,7 @@
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";
@@ -105,6 +105,34 @@ export function markHandedOff(sessionId, curTokens = 0) {
105
105
 
106
106
  function nowSec() { try { return Number(execSync("date +%s", { encoding: "utf8" }).trim()) || 0; } catch { return 0; } }
107
107
 
108
+ // ---- in-flight guard --------------------------------------------------------
109
+ // True when this session is actively orchestrating sub-agents (Agent/Task tool, Workflow swarms,
110
+ // agent-teams): any `agent-*.jsonl` under <transcriptDir>/<sid>/subagents/ (incl. workflows/) was
111
+ // written within `withinMs`. The auto baton-pass uses this to DEFER — we must never yank a fresh
112
+ // window up (or, before the 2026-06-21 fix, kill the original) while real in-flight agent work is
113
+ // running. INCIDENT 2026-06-21: a 90% baton fired mid 2-agent build and the original session was
114
+ // SIGKILLed mid-flight. Best-effort; returns false on any error.
115
+ export function subagentsActive(transcriptPath, withinMs = 90_000) {
116
+ try {
117
+ if (!transcriptPath) return false;
118
+ const sub = join(dirname(transcriptPath), basename(transcriptPath).replace(/\.jsonl$/i, ""), "subagents");
119
+ if (!existsSync(sub)) return false;
120
+ const cutoff = Date.now() - withinMs;
121
+ const stack = [sub];
122
+ while (stack.length) {
123
+ const d = stack.pop();
124
+ let entries; try { entries = readdirSync(d, { withFileTypes: true }); } catch { continue; }
125
+ for (const e of entries) {
126
+ const p = join(d, e.name);
127
+ if (e.isDirectory()) { stack.push(p); continue; }
128
+ if (!/^agent-.*\.jsonl$/i.test(e.name)) continue;
129
+ try { if (statSync(p).mtimeMs >= cutoff) return true; } catch {}
130
+ }
131
+ }
132
+ return false;
133
+ } catch { return false; }
134
+ }
135
+
108
136
  // ---- whole-session summary --------------------------------------------------
109
137
  function collectTurns(transcriptPath) {
110
138
  const rows = readFileSync(transcriptPath, "utf8").split("\n").filter(Boolean)
@@ -262,10 +290,17 @@ export function terminalWindowForTty(tty) {
262
290
  // Arm the baton-close watcher: a DETACHED process that waits until the fresh session consumes the
263
291
  // handoff (consumed:true), then closes the original Terminal window. Never closes blind: aborts on
264
292
  // timeout (fresh never showed) and re-validates the window's tty before closing.
265
- export function armBatonClose(handoffFile, originalWindowId, originalTty, conf = readConfig()) {
293
+ export function armBatonClose(handoffFile, originalWindowId, originalTty, conf = readConfig(), { auto = false } = {}) {
266
294
  try {
267
295
  if (process.platform !== "darwin" || !originalWindowId) return false;
268
296
  if (process.env.TRANTOR_NO_BATON_CLOSE === "1" || conf.batonClose === false) return false;
297
+ // SAFETY (incident 2026-06-21): an AUTOMATIC baton must NEVER close the original session. At 90%
298
+ // (10% headroom) mid 2-agent build, auto-close SIGKILLed the original window's processes and killed
299
+ // in-flight work — the scariest possible failure. Auto-close is now strictly opt-in
300
+ // (config.autoCloseOriginal:true). The default auto baton just opens the fresh window and LEAVES the
301
+ // original alive. Manual /trantor:handoff still closes (the user explicitly invoked a wrap-up) — and
302
+ // even that is now non-destructive (baton-close never SIGKILLs and aborts if the original is busy).
303
+ if (auto && conf.autoCloseOriginal !== true) return false;
269
304
  const closer = join(HERE, "..", "..", "bin", "baton-close.mjs");
270
305
  if (!existsSync(closer)) return false;
271
306
  const child = spawn(process.execPath, [closer, handoffFile, String(originalWindowId), originalTty || ""], { detached: true, stdio: "ignore" });
@@ -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: arm the close of THIS window once the fresh session takes over (we have the tty here)
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.29",
3
+ "version": "0.17.30",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"