trantor 0.18.11 → 0.18.12

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.12",
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"}`
@@ -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
  }
@@ -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.12",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"