trantor 0.18.11 → 0.18.13

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.18.11",
3
+ "version": "0.18.13",
4
4
  "description": "Trantor \u2014 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/advise.mjs CHANGED
@@ -90,14 +90,19 @@ export function loadWorld() {
90
90
 
91
91
  const tierOf = (profile, prov) => profile?.providers?.[prov]?.tier || "api";
92
92
 
93
+ const FLASH_TIER = /(flash|turbo|lite|mini|highspeed|small)/i;
94
+
93
95
  // pick the cheapest Scrooge model that clears the difficulty floor for a task kind
94
96
  export function scroogeModelFor(registry, caps, kind = "code", difficulty = "easy") {
95
97
  const floor = { easy: 0, medium: 35, hard: 55 }[difficulty] ?? 0;
96
98
  const cands = Object.entries(registry.models || {})
97
99
  .filter(([, m]) => (m.good_for || []).includes(kind))
98
- .filter(([id]) => (caps[id]?.coding ?? caps[id]?.intelligence ?? 40) >= floor)
99
- .sort((a, b) => (a[1].cost_in + a[1].cost_out) - (b[1].cost_in + b[1].cost_out));
100
- return cands[0] ? { model: cands[0][0], cost_in: cands[0][1].cost_in, cost_out: cands[0][1].cost_out } : null;
100
+ .filter(([id]) => (caps[id]?.coding ?? caps[id]?.intelligence ?? 40) >= floor);
101
+ const hard = difficulty === "hard";
102
+ const strong = hard ? cands.filter(([id]) => !FLASH_TIER.test(id)) : cands;
103
+ const pool = strong.length > 0 ? strong : cands;
104
+ pool.sort((a, b) => (a[1].cost_in + a[1].cost_out) - (b[1].cost_in + b[1].cost_out));
105
+ return pool[0] ? { model: pool[0][0], cost_in: pool[0][1].cost_in, cost_out: pool[0][1].cost_out } : null;
101
106
  }
102
107
 
103
108
  // crude per-package token forecast (input+output through the executor)
package/bin/baton.mjs CHANGED
@@ -35,6 +35,13 @@ const transcript = findTranscript();
35
35
  const sessionId = transcript ? basename(transcript, ".jsonl") : "";
36
36
  const { file } = writeHandoff({ projectDir: cwd, sessionId, transcript, trigger: "manual-cli", force: true }); // manual = intentional, bypass the storm guard
37
37
  console.log(`📋 handoff saved for ${project}: ${file}`);
38
+ // --write-only: the in-app flow (#5509). The app ends the pane's session itself and reopens it
39
+ // through `trantor open`, which claims this handoff — a Terminal window here would be exactly the
40
+ // wrong surface, so the flag writes, announces, and stops.
41
+ if (process.argv.includes("--write-only")) {
42
+ console.log(`🔄 write-only: no window spawned — the pane takeover (trantor open) claims it next.`);
43
+ process.exit(0);
44
+ }
38
45
  const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
39
46
  console.log(spawned
40
47
  ? `🔄 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"}`
package/bin/cli.mjs CHANGED
@@ -73,6 +73,7 @@ switch (cmd) {
73
73
  case "recost": run("bin/recost.mjs"); break;
74
74
  case "handoff": run("bin/baton.mjs"); break;
75
75
  case "adopt": run("bin/adopt.mjs"); break;
76
+ case "takeover": run("bin/takeover.mjs"); break;
76
77
  case "summarize": run("bin/summarize.mjs"); break;
77
78
  case "policy": run("bin/policy.mjs"); break;
78
79
  case "proposals": case "proposal": run("bin/proposals.mjs"); break;
@@ -195,6 +196,7 @@ switch (cmd) {
195
196
  trantor herdr install|remove|status the login agent that keeps panes alive across a reboot
196
197
  trantor autonomy how much Trantor may do unasked: seats, your harness, commit/push/deploy
197
198
  trantor adopt take over a session already running in a Terminal, then open it here
199
+ trantor takeover the whole move in one command: idle-gate the Terminal session, end it gracefully, adopt, open in the pane — [--force] [--session <id>] [--dry-run]
198
200
  trantor integrate collect the crew's work, merge it, verify it, push it (--dry-run to rehearse)
199
201
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
200
202
  trantor prune drop dead crew-window tracking rows (ghost workspaces/panes) without spawning anything
@@ -44,7 +44,22 @@ function ensureSeatWorktree(sourceDir) {
44
44
  const branch = `seat/${AGENT}`;
45
45
  if (existsSync(seatDir)) {
46
46
  const ok = gitOut(["-C", seatDir, "rev-parse", "--is-inside-work-tree"], seatDir) === "true";
47
- if (ok) return seatDir;
47
+ if (ok) {
48
+ // #5403: a worktree created once builds against THAT day's main forever — every wave since
49
+ // has needed a hand fast-forward. Refresh only when it is CLEAN: a dirty tree is a seat's
50
+ // unintegrated work and a diverged branch is a decision, and refreshing must never eat
51
+ // either. Failure to refresh is loud but non-fatal: stale beats broken.
52
+ const dirty = gitOut(["-C", seatDir, "status", "--porcelain"], seatDir);
53
+ if (dirty === "") {
54
+ const head = gitOut(["-C", root, "rev-parse", "HEAD"], root);
55
+ const ff = head && spawnSync("git", ["-C", seatDir, "merge", "--ff-only", head], { stdio: "ignore", timeout: 15000 });
56
+ if (ff && ff.status === 0) console.log(`\x1b[2m[runner]\x1b[0m ${branch} worktree refreshed to ${head.slice(0, 7)}`);
57
+ else console.log(`\x1b[33m[runner]\x1b[0m ${branch} worktree diverged from main HEAD — left as-is (integrate or reset it)`);
58
+ } else {
59
+ console.log(`\x1b[33m[runner]\x1b[0m ${branch} worktree has uncommitted work — not refreshed`);
60
+ }
61
+ return seatDir;
62
+ }
48
63
  console.log(`\x1b[33m[runner]\x1b[0m worktree path exists but is not a git worktree: ${seatDir} — using ${sourceDir}`);
49
64
  return sourceDir;
50
65
  }
package/bin/models.mjs CHANGED
@@ -25,9 +25,14 @@ const liveModels = (providerOc) => {
25
25
  catch { return []; }
26
26
  };
27
27
 
28
+ const FLASH_TIER = /(flash|turbo|lite|mini|highspeed|small)/i;
29
+
28
30
  function routePick(candList, diff) {
29
31
  try {
30
- const out = execSync(`python3 ${SCROOGE} route --candidates ${JSON.stringify(candList.join(" "))} -t code -d ${diff} --json 2>/dev/null`, { encoding: "utf8" });
32
+ const hard = diff === "hard";
33
+ const strong = hard ? candList.filter(id => !FLASH_TIER.test(id)) : candList;
34
+ const pool = strong.length > 0 ? strong : candList;
35
+ const out = execSync(`python3 ${SCROOGE} route --candidates ${JSON.stringify(pool.join(" "))} -t code -d ${diff} --json 2>/dev/null`, { encoding: "utf8" });
31
36
  return JSON.parse(out).qualified || "?";
32
37
  } catch { return "?"; }
33
38
  }
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ // `trantor takeover` — one command from "the conversation lives in a Terminal window" to "it
3
+ // lives in the pane" (#5495, design: docs/DESIGN-takeover-visibility.md).
4
+ //
5
+ // The chain: inventory → idle gate → graceful end → adopt → open. CLI-first on purpose: the
6
+ // app's button shells THIS command (takeover_now), so the terminal user and the button share one
7
+ // tested implementation — the handoff_now pattern.
8
+ //
9
+ // What this must never do, from the design:
10
+ // - never end a session that wrote its transcript seconds ago without --force (in-flight work);
11
+ // - never pick silently between two live candidates (refuse and show both; --session decides);
12
+ // - never leave the operator with nothing: if open fails after the terminal claude exited, print
13
+ // the exact `claude --resume <sid>` that recovers the thread by hand.
14
+ import { readdirSync, statSync, existsSync } from "node:fs";
15
+ import { join, dirname } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import { fileURLToPath } from "node:url";
18
+ import { execFileSync, spawnSync } from "node:child_process";
19
+ import { resolveProject } from "../lib/project.mjs";
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url));
22
+ const args = process.argv.slice(2);
23
+ const flag = (n) => args.includes(n);
24
+ const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
25
+ const JSON_OUT = flag("--json");
26
+ const stages = [];
27
+ const say = (s) => { stages.push(s); if (!JSON_OUT) console.log(s); };
28
+ const out = (ok, extra = {}) => {
29
+ if (JSON_OUT) console.log(JSON.stringify({ ok, stages, ...extra }));
30
+ process.exit(ok ? 0 : 2);
31
+ };
32
+
33
+ const project = args.find(a => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--session")
34
+ || resolveProject(process.cwd());
35
+ const devRoot = process.env.TRANTOR_DEV_ROOT || join(homedir(), "development");
36
+ const dir = join(devRoot, project);
37
+ if (!existsSync(dir)) { say(`no local checkout for ${project} (looked in ${devRoot})`); out(false, { reason: "no-checkout" }); }
38
+
39
+ // The idle gate: a transcript written this recently means the session is MID-TURN, and ending it
40
+ // would eat in-flight work. Overridable for drills and deliberate --force.
41
+ export const IDLE_GATE_SEC = Number(process.env.TRANTOR_TAKEOVER_IDLE_SEC || 15);
42
+
43
+ /** The decision table, pure so it can be drilled without processes (test-takeover.mjs). */
44
+ export function decide({ terminalPids, candidates, sessionFlag, force, idleGateSec = IDLE_GATE_SEC }) {
45
+ if (!terminalPids.length) return { action: "open", reason: "no terminal session — plain open (start or reopen the pane)" };
46
+ if (terminalPids.length > 1) {
47
+ return { action: "refuse", reason: `${terminalPids.length} claude sessions run in this directory (pids ${terminalPids.join(", ")}) — close the extras first; a takeover must know which conversation it is adopting` };
48
+ }
49
+ if (!candidates.length) return { action: "refuse", reason: "a claude runs here but no transcript has been written in the last hour — nothing safe to adopt" };
50
+ const chosen = sessionFlag ? candidates.find(c => c.id === sessionFlag) : candidates[0];
51
+ if (sessionFlag && !chosen) return { action: "refuse", reason: `${sessionFlag} is not among the recent transcripts here` };
52
+ if (!sessionFlag && candidates.length > 1) {
53
+ const list = candidates.slice(0, 4).map(c => `${c.id} (${c.ageSec}s ago)`).join(" · ");
54
+ return { action: "refuse", reason: `two live conversations here — pick one with --session <id>: ${list}` };
55
+ }
56
+ if (chosen.ageSec < idleGateSec && !force) {
57
+ return { action: "refuse", reason: `looks MID-TURN (transcript written ${chosen.ageSec}s ago, gate ${idleGateSec}s) — wait for the turn to finish, or --force` };
58
+ }
59
+ return { action: "takeover", sid: chosen.id, pid: terminalPids[0] };
60
+ }
61
+
62
+ // ---- inventory (process + filesystem truth only) ----------------------------------------------
63
+ function paneForegroundPgid() {
64
+ try {
65
+ const rows = execFileSync("cat", [join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "crew-windows.txt")], { encoding: "utf8" });
66
+ const pane = rows.split("\n").map(l => l.split("\t")).find(f => f[0] === project && f[1] === "orch")?.[3];
67
+ if (!pane) return 0;
68
+ const info = execFileSync("herdr", ["pane", "process-info", "--pane", pane], { encoding: "utf8", timeout: 6000 });
69
+ return Number(JSON.parse(info.slice(info.search(/[[{]/)))?.result?.process_info?.foreground_process_group_id) || 0;
70
+ } catch { return 0; }
71
+ }
72
+
73
+ function terminalClaudePids() {
74
+ let pids = [];
75
+ try { pids = execFileSync("/usr/bin/pgrep", ["-x", "claude"], { encoding: "utf8" }).split("\n").filter(Boolean); } catch { return []; }
76
+ const panePgid = paneForegroundPgid();
77
+ const mine = [];
78
+ for (const pid of pids) {
79
+ if (Number(pid) === panePgid) continue; // the pane's own claude is not a "terminal session"
80
+ try {
81
+ const cwd = execFileSync("/usr/sbin/lsof", ["-a", "-d", "cwd", "-p", pid, "-Fn"], { encoding: "utf8" })
82
+ .split("\n").find(l => l.startsWith("n"))?.slice(1);
83
+ if (cwd === dir) mine.push(Number(pid));
84
+ } catch { /* raced away */ }
85
+ }
86
+ return mine;
87
+ }
88
+
89
+ function recentCandidates() {
90
+ const slug = dir.replace(/[/.]/g, "-");
91
+ const tdir = join(process.env.TRANTOR_CLAUDE_DIR || join(homedir(), ".claude", "projects"), slug);
92
+ if (!existsSync(tdir)) return [];
93
+ const now = Date.now();
94
+ return readdirSync(tdir).filter(f => f.endsWith(".jsonl"))
95
+ .map(f => { const st = statSync(join(tdir, f)); return { id: f.replace(/\.jsonl$/, ""), ageSec: Math.round((now - st.mtimeMs) / 1000) }; })
96
+ .filter(c => c.ageSec < 3600)
97
+ .sort((a, b) => a.ageSec - b.ageSec);
98
+ }
99
+
100
+ // ---- the chain --------------------------------------------------------------------------------
101
+ // Run the chain ONLY when this file is the entrypoint. The first cut used
102
+ // argv[1].endsWith("takeover.mjs"), which is also true for test-takeover.mjs — importing the
103
+ // decision table from the drill file executed a real (luckily idempotent) pane open.
104
+ import { basename as _bn } from "node:path";
105
+ if (process.argv[1] && _bn(process.argv[1]) === "takeover.mjs") {
106
+ const d = decide({ terminalPids: terminalClaudePids(), candidates: recentCandidates(), sessionFlag: opt("--session"), force: flag("--force") });
107
+ if (flag("--dry-run")) { say(`dry-run: ${d.action}${d.reason ? ` — ${d.reason}` : ""}${d.sid ? ` (sid ${d.sid}, pid ${d.pid})` : ""}`); out(true, { decision: d }); }
108
+ if (d.action === "refuse") { say(d.reason); out(false, { reason: d.reason }); }
109
+
110
+ if (d.action === "takeover") {
111
+ say(`ending Terminal session pid ${d.pid} (idle ${IDLE_GATE_SEC}s gate passed)`);
112
+ try { process.kill(d.pid, "SIGTERM"); } catch {}
113
+ const deadline = Date.now() + 8000;
114
+ let alive = true;
115
+ while (alive && Date.now() < deadline) {
116
+ try { process.kill(d.pid, 0); spawnSync("sleep", ["0.3"]); } catch { alive = false; }
117
+ }
118
+ if (alive) { try { process.kill(d.pid, "SIGKILL"); } catch {} say("did not exit in 8s — killed"); }
119
+ else say("session ended cleanly");
120
+
121
+ const adopt = spawnSync(process.execPath, [join(HERE, "adopt.mjs"), project, "--session", d.sid], { encoding: "utf8", timeout: 20000 });
122
+ if (adopt.status !== 0) { say(`adopt failed: ${(adopt.stderr || adopt.stdout || "").trim().slice(0, 200)}`); out(false, { reason: "adopt-failed", sid: d.sid }); }
123
+ say(`adopted ${d.sid} as ${project}'s orchestrator thread`);
124
+ }
125
+
126
+ const open = spawnSync("bash", [join(HERE, "crew.sh"), "open", project], { cwd: dir, encoding: "utf8", timeout: 120000 });
127
+ if (open.status !== 0) {
128
+ say(`open failed: ${(open.stderr || "").trim().slice(0, 200)}`);
129
+ if (d.sid) say(`the conversation is safe on disk — recover by hand: claude --resume ${d.sid}`);
130
+ out(false, { reason: "open-failed", sid: d.sid || null });
131
+ }
132
+ say(`pane hosted: ${(open.stdout || "").trim().split("\n").pop()}`);
133
+ out(true, { sid: d.sid || null });
134
+ }
@@ -423,6 +423,10 @@ export function maybeSpawn(projectDir, conf = readConfig()) {
423
423
  if (process.platform !== "darwin") return false;
424
424
  if (process.env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
425
425
  if (conf.autoHandoffPrompt === false) return false;
426
+ if (hasOrchPane(basename(projectDir))) {
427
+ process.stderr.write(`[trantor] orch pane hosts ${basename(projectDir)} — no Terminal window; the pane claims the handoff on its next open\n`);
428
+ return false;
429
+ }
426
430
  const script = join(HERE, "..", "..", "bin", "handoff-prompt.sh");
427
431
  if (!existsSync(script)) { process.stderr.write(`[trantor] handoff-prompt.sh missing\n`); return false; }
428
432
  const timeout = String(conf.handoffPromptTimeout || 25);
@@ -446,9 +450,26 @@ export function spawnSuppressed() {
446
450
  return process.env.TRANTOR_NO_HANDOFF_SPAWN === "1" || process.env.TRANTOR_NO_BATON_SPAWN === "1";
447
451
  }
448
452
 
453
+ // Does this project have a hosted orchestrator pane? When it does, the PANE is the successor
454
+ // surface: `trantor open` claims the handoff there, and spawning a Terminal window would put the
455
+ // fresh session on exactly the surface the operator is trying to leave (#5509 W1). The tracked
456
+ // row is the signal — rows are recorded by open and dropped by teardown/prune, and a stale row
457
+ // costs only a skipped window, never a lost handoff (the handoff waits, held for the pane).
458
+ export function hasOrchPane(projectName) {
459
+ try {
460
+ const state = join(process.env.AGENT_BUS_DIR || process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus"), "crew-windows.txt");
461
+ if (!existsSync(state)) return false;
462
+ return readFileSync(state, "utf8").split("\n").some(l => {
463
+ const f = l.split("\t");
464
+ return f[0] === projectName && f[1] === "orch";
465
+ });
466
+ } catch { return false; }
467
+ }
468
+
449
469
  export function spawnFresh(projectDir) {
450
470
  try {
451
471
  if (process.platform !== "darwin" || spawnSuppressed()) return false;
472
+ if (hasOrchPane(basename(projectDir))) return false; // the pane is the successor surface (#5509)
452
473
  const script = join(HERE, "..", "..", "bin", "open-session.sh");
453
474
  if (!existsSync(script)) return false;
454
475
  const child = spawn("/bin/bash", [script, projectDir, RECAP_CMD], { detached: true, stdio: "ignore" });
package/hub.mjs CHANGED
@@ -752,6 +752,31 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
752
752
  if (c.ageMs < CONTRACT_ABANDON_MS) continue;
753
753
  if (c.ts < (newestAnswered.get(c.to) || 0)) c.disposition = "superseded";
754
754
  }
755
+ // ---- superseded by a later DIRECT reply: the morning case (#11047/#11048) --------------------
756
+ // A row can be unanswerable by a seat that is perfectly healthy: its later replies all carry `re`
757
+ // for NEWER contracts (a re-dispatch, or an "ack by reference" threaded to the newer id), so
758
+ // neither byRe nor the loose fallback ever claims the old row — the two matchers above only ever
759
+ // see replies aimed at the NEWER work. The row then sits WAITING forever: it can never be
760
+ // answered, it can never be abandoned (the seat is alive), and the newestAnswered rule above
761
+ // misses it whenever the newer work was dispatched under a peer identity the old row never shares.
762
+ //
763
+ // The signal both matchers ignored is the DIRECT reply itself: if the assignee has sent this
764
+ // session ANY message after the row was dispatched, the assignee is alive, reachable, and has
765
+ // demonstrably moved on to later work — an older row that has then sat unanswered past the
766
+ // abandon window is dead weight, not in flight. Age still gates it, so honest out-of-order
767
+ // completion (a seat that answered a newer job while still working an older one) is never
768
+ // punished — the older row stays open until the window that any genuine in-flight job would have
769
+ // reported within has passed.
770
+ const latestDirectReply = new Map();
771
+ for (const r of replies) {
772
+ if (r.ts > (latestDirectReply.get(r.from) || 0)) latestDirectReply.set(r.from, r.ts);
773
+ }
774
+ for (const c of out) {
775
+ if (c.answered || c.disposition === "abandoned") continue;
776
+ if (c.ageMs < CONTRACT_ABANDON_MS) continue;
777
+ const latest = latestDirectReply.get(c.to);
778
+ if (latest != null && c.ts < latest) c.disposition = "superseded";
779
+ }
755
780
  return out;
756
781
  }
757
782
 
@@ -1821,6 +1846,13 @@ const server = http.createServer(async (req, res) => {
1821
1846
  (t.history ||= []).push({ from: t.status, to: b.status, by: b.by || "", ts: now() });
1822
1847
  if (t.history.length > 40) t.history.splice(0, 10);
1823
1848
  t.status = b.status;
1849
+ // WHO is actually working this card — the SIGNED mover, not the assignee. A card filed by
1850
+ // the orchestrator and built by a seat wore the orchestrator's face on every board
1851
+ // (2026-08-28, operator caught it: "they all say claude"). The assignee stays intent;
1852
+ // workedBy is evidence, stamped only on real work moves and never from a self-asserted by.
1853
+ if (["doing","testing","done"].includes(b.status) && auth?.identity?.name) {
1854
+ t.workedBy = String(auth.identity.name).slice(0, 120);
1855
+ }
1824
1856
  }
1825
1857
  if (b.difficulty && ["easy","medium","hard"].includes(b.difficulty)) t.difficulty = b.difficulty;
1826
1858
  if (b.model !== undefined) t.model = String(b.model).slice(0, 60);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.11",
3
+ "version": "0.18.13",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"