trantor 0.17.24 → 0.17.26

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.24"
9
+ "version": "0.17.25"
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.24",
16
+ "version": "0.17.25",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.24",
3
+ "version": "0.17.26",
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/README.md CHANGED
@@ -42,6 +42,41 @@ claude plugin install trantor
42
42
  That's it. (Prefer source? `git clone https://github.com/sashabogi/trantor && cd trantor &&
43
43
  npm install && bash deploy/setup.sh` — identical result.)
44
44
 
45
+ ## What gets installed — footprint & safety
46
+
47
+ Trantor is a **local-first multi-agent orchestrator with a built-in cost router** — not a cloud
48
+ service, and not an agent that runs off on its own. Here is *exactly* what the two steps above put
49
+ on your machine, so you (or an agent installing it for you) can see the whole footprint up front:
50
+
51
+ **`npm install -g trantor` + `trantor setup`**
52
+ - The `trantor` CLI — one global npm package.
53
+ - `~/.agent-bus/` — a single local directory holding **all** state: `config.json`, the board data
54
+ (`bus.json`), and `.env` for any **provider API keys you choose to add** (e.g. `DEEPSEEK_API_KEY`).
55
+ Nothing in here ever leaves your machine.
56
+ - A local **hub** at `http://127.0.0.1:4477` — **loopback only**, not reachable from the network. On
57
+ macOS it's a launchd agent (`com.trantor.hub`) so it restarts at login; on Linux you run it yourself.
58
+ - The economics engine (Scrooge) into `~/.local/bin` — the cost ledger and cheap-model router.
59
+
60
+ **`claude plugin install trantor`** adds, inside Claude Code only:
61
+ - An MCP server (`relay`) exposing the `relay_*` tools, plus the `/trantor:*` skills.
62
+ - Hooks on four events — local Node scripts that only POST to the loopback hub: **SessionStart**
63
+ (register the session + show the live roster), **PostToolUse** (presence heartbeat + mirror your
64
+ TodoWrite list onto the board), **PreCompact** (write a handoff before the context window compacts),
65
+ **SubagentStop** (record each sub-agent's notional cost on the board).
66
+
67
+ **What it does *not* do:** no cloud, no accounts, no telemetry, nothing phones home; it never uploads
68
+ your code or keys; it doesn't touch other CLIs' credentials — Codex, Gemini, Kimi and DeepSeek are
69
+ ones *you* already installed and signed into, and Trantor just coordinates them locally. The optional
70
+ API keys in `~/.agent-bus/.env` are used only to call the cheap models *you* opted into for routing.
71
+
72
+ **Remove everything, anytime:**
73
+ ```bash
74
+ claude plugin uninstall trantor # drop the MCP tools, skills, and hooks
75
+ launchctl bootout gui/$(id -u)/com.trantor.hub # stop the hub service (macOS)
76
+ rm -f ~/Library/LaunchAgents/com.trantor.hub.plist
77
+ rm -rf ~/.agent-bus # delete all local state + keys
78
+ ```
79
+
45
80
  ## What to expect on first run
46
81
 
47
82
  `trantor setup` ends with the **doctor** — an honest map of where you stand:
package/bin/advise.mjs CHANGED
@@ -14,6 +14,7 @@ import { readFileSync, existsSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { execSync } from "node:child_process";
17
+ import { pathToFileURL } from "node:url";
17
18
 
18
19
  const H = homedir();
19
20
  const read = (p, fb) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return fb; } };
@@ -134,7 +135,9 @@ export function advise(input, world = loadWorld()) {
134
135
  }
135
136
 
136
137
  // ---- CLI ----
137
- if (import.meta.url === `file://${process.argv[1]}`) {
138
+ // is-main guard via PROPERLY ENCODED file URL — a hand-built `file://${argv[1]}` silently no-ops when the
139
+ // install path contains a URL-reserved char (e.g. a SPACE in ".../Application Support/..."). See profile.mjs.
140
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
138
141
  let input;
139
142
  if (process.argv.includes("--demo")) {
140
143
  input = { task: "neon asteroids game", packages: [
@@ -7,13 +7,55 @@
7
7
  // NEVER close the wrong window. Args: <handoffFile> <originalWindowId> <originalTty>
8
8
  import { readFileSync, existsSync } from "node:fs";
9
9
  import { execSync } from "node:child_process";
10
+ import { pathToFileURL } from "node:url";
10
11
 
11
12
  const [, , handoffFile, windowId, originalTty] = process.argv;
12
- const POLL_MS = 1500, TIMEOUT_MS = 120_000;
13
+ const POLL_MS = 1500, TIMEOUT_MS = 180_000;
14
+ // Once the handoff is consumed (= injected into the fresh session's context), wait up to this long for
15
+ // the fresh session to actually PRODUCE its first assistant turn (it boots with `claude 'Recap…'`, so it
16
+ // genuinely reads the handoff and replies) before we close the original. If the fresh session never
17
+ // emits a turn (e.g. it crashed, or a non-recap spawn), we close anyway after this grace so we don't
18
+ // leave two windows forever — the handoff is already in its context, so there's no data loss.
19
+ const ENGAGE_GRACE_MS = 45_000;
13
20
 
14
- const consumed = () => { try { return JSON.parse(readFileSync(handoffFile, "utf8")).consumed === true; } catch { return false; } };
21
+ const handoff = () => { try { return JSON.parse(readFileSync(handoffFile, "utf8")); } catch { return null; } };
22
+ const consumed = () => handoff()?.consumed === true;
15
23
  const sleep = ms => new Promise(r => setTimeout(r, ms));
16
24
 
25
+ // True once the fresh session's transcript shows an assistant turn at/after the handoff was consumed —
26
+ // i.e. the new model has actually read the handoff and started driving (its recap reply). A brand-new
27
+ // session's transcript has no prior assistant turns, so the first one to appear IS the takeover.
28
+ export function freshEngaged(rec) {
29
+ try {
30
+ const tp = rec?.consumedBy?.transcript_path;
31
+ if (!tp || !existsSync(tp)) return false;
32
+ const consumedAt = Number(rec.consumedAt) || 0;
33
+ const lines = readFileSync(tp, "utf8").split("\n");
34
+ for (const ln of lines) {
35
+ if (!ln) continue;
36
+ let r; try { r = JSON.parse(ln); } catch { continue; }
37
+ if (r?.type !== "assistant") continue;
38
+ const ts = r.timestamp ? Math.floor(Date.parse(r.timestamp) / 1000) : 0;
39
+ if (!consumedAt || !ts || ts >= consumedAt - 5) return true;
40
+ }
41
+ return false;
42
+ } catch { return false; }
43
+ }
44
+
45
+ // Wait for genuine takeover (fresh assistant turn) with a bounded grace. Returns when engaged or grace
46
+ // elapsed. If the handoff carries no consumedBy transcript (older record), there's nothing to watch — a
47
+ // short settle and proceed, preserving prior behavior.
48
+ async function waitForTakeover() {
49
+ const rec = handoff();
50
+ if (!rec?.consumedBy?.transcript_path) { await sleep(2500); return; }
51
+ const until = Date.now() + ENGAGE_GRACE_MS;
52
+ while (Date.now() < until) {
53
+ if (freshEngaged(handoff())) { await sleep(800); return; } // small settle after the turn lands
54
+ await sleep(POLL_MS);
55
+ }
56
+ 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
+ }
58
+
17
59
  function ttyOfWindow(id) {
18
60
  try {
19
61
  return execSync(`osascript -e ${JSON.stringify(`tell application "Terminal" to get tty of selected tab of (first window whose id is ${id})`)}`,
@@ -37,12 +79,15 @@ function closeWindow(id, tty) {
37
79
  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
80
  }
39
81
 
40
- (async () => {
82
+ // Only run the close watcher when executed directly — importing this file (tests) must not block.
83
+ const isMain = (() => { try { return import.meta.url === pathToFileURL(process.argv[1]).href; } catch { return false; } })();
84
+
85
+ if (isMain) (async () => {
41
86
  if (!handoffFile || !windowId) process.exit(0);
42
87
  const deadline = Date.now() + TIMEOUT_MS;
43
88
  while (Date.now() < deadline) {
44
89
  if (consumed()) {
45
- await sleep(2500); // let the fresh session settle (register, inject) before we pull the original
90
+ await waitForTakeover(); // don't close until the fresh session has actually produced its recap turn
46
91
  const ok = closeWindow(windowId, originalTty);
47
92
  process.stderr.write(`[trantor] baton-close: fresh session took over → original window ${windowId} ${ok ? "closed" : "left (validation/close failed)"}\n`);
48
93
  process.exit(0);
package/bin/doctor.mjs CHANGED
@@ -54,9 +54,13 @@ else {
54
54
  console.log("\ncrew CLIs (install any subset — seats follow the work)");
55
55
  const CLIS = [
56
56
  { name: "codex", bin: "codex", wired: () => (readFileSync(join(H, ".codex", "config.toml"), "utf8")).includes("[mcp_servers.relay]"), auth: () => existsSync(join(H, ".codex", "auth.json")), login: "codex (sign in with your ChatGPT account on first run)" },
57
- { name: "gemini", bin: "gemini", wired: () => !!read(join(H, ".gemini", "settings.json"))?.mcpServers?.relay, auth: () => existsSync(join(H, ".gemini", "oauth_creds.json")) || !!process.env.GEMINI_API_KEY || !!process.env.GOOGLE_API_KEY, login: "gemini (Google sign-in on first run, or set GEMINI_API_KEY)" },
57
+ // Gemini CLI was retired 2026-06-18 for free/Pro/Ultra (Google Antigravity `agy`). Kept as an
58
+ // optional seat for enterprise/paid-key holders; for everyone else the seat moved to GLM/opencode,
59
+ // and Gemini lives on only as a Scrooge cheap-model via GEMINI_API_KEY (the API/models aren't retired).
60
+ { name: "gemini (CLI retired 2026-06-18)", bin: "gemini", wired: () => !!read(join(H, ".gemini", "settings.json"))?.mcpServers?.relay, auth: () => existsSync(join(H, ".gemini", "oauth_creds.json")) || !!process.env.GEMINI_API_KEY || !!process.env.GOOGLE_API_KEY, login: "Gemini CLI retired 2026-06-18 (free/Pro/Ultra). Crew seat → GLM (opencode) or Antigravity `agy`. Gemini still serves as a Scrooge cheap-model via GEMINI_API_KEY." },
58
61
  { name: "kimi", bin: "kimi", wired: () => !!read(join(H, ".kimi", "mcp.json"))?.mcpServers?.relay, auth: () => existsSync(join(H, ".kimi", "credentials")), login: "kimi → /login (Kimi account or Moonshot API key)" },
59
62
  { name: "deepseek (via opencode)", bin: "opencode", wired: () => !!read(join(H, ".config", "opencode", "opencode.json"))?.mcp?.relay, auth: () => !!process.env.DEEPSEEK_API_KEY || (existsSync(join(H, ".agent-bus", ".env")) && readFileSync(join(H, ".agent-bus", ".env"), "utf8").includes("DEEPSEEK_API_KEY")) || !!read(join(H, ".local", "share", "opencode", "auth.json")), login: `get a key at platform.deepseek.com, then: echo 'DEEPSEEK_API_KEY=sk-…' >> ~/.agent-bus/.env` },
63
+ { name: "glm (via opencode · coding plan)", bin: "opencode", wired: () => !!read(join(H, ".config", "opencode", "opencode.json"))?.mcp?.relay, auth: () => !!read(join(H, ".config", "opencode", "opencode.json"))?.provider?.["zai-coding-plan"]?.options?.apiKey, login: `put your Z.ai coding-plan key at ~/.config/opencode/opencode.json → provider["zai-coding-plan"].options.apiKey, then seat: trantor up opencode:zai-coding-plan/glm-5.1` },
60
64
  ];
61
65
  let installed = 0;
62
66
  for (const c of CLIS) {
package/bin/profile.mjs CHANGED
@@ -12,9 +12,10 @@
12
12
  // for real builds; the plan's quota is a scarce budget)
13
13
  // max | max-5x | max-20x | ultra — high-tier subscription (cost moot; context horizon decides)
14
14
  // none — provider not available on this machine
15
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
16
- import { join } from "node:path";
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
17
  import { homedir } from "node:os";
18
+ import { pathToFileURL } from "node:url";
18
19
 
19
20
  const FILE = join(homedir(), ".agent-bus", "profile.json");
20
21
  const KNOWN = ["claude", "codex", "gemini", "kimi", "deepseek", "opencode"];
@@ -32,7 +33,11 @@ export function loadProfile() {
32
33
  export function tierOf(profile, provider) { return TIER(profile?.providers?.[provider]?.plan); }
33
34
 
34
35
  const [, , cmd, ...args] = process.argv;
35
- if (import.meta.url === `file://${process.argv[1]}`) {
36
+ // is-main guard: compare against a PROPERLY ENCODED file URL. A hand-built `file://${argv[1]}` is raw
37
+ // text, but import.meta.url is percent-encoded — so any URL-reserved char in the install path (most
38
+ // commonly a SPACE, e.g. ".../Application Support/...") made this false and silently skipped main (exit 0,
39
+ // no write). pathToFileURL is the canonical Node idiom. See regression in test-handoff.mjs / test.mjs.
40
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
36
41
  const prof = loadProfile();
37
42
  prof.providers ||= {};
38
43
  if (cmd === "set") {
@@ -42,6 +47,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
42
47
  prof.providers[prov.toLowerCase()] = { plan: plan.toLowerCase(), tier: TIER(plan) };
43
48
  }
44
49
  prof.updated = new Date().toISOString().slice(0, 10);
50
+ mkdirSync(dirname(FILE), { recursive: true }); // `set` creates its own ~/.agent-bus if absent
45
51
  writeFileSync(FILE, JSON.stringify(prof, null, 2) + "\n");
46
52
  console.log("profile saved →", FILE);
47
53
  } else if (cmd && cmd !== "show") {
@@ -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, controllingTty, terminalWindowForTty } from "./lib/handoff.mjs";
21
+ import { readConfig, contextUsage, warnFrac, alreadyHandedOff, markHandedOff, 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);
@@ -64,6 +64,12 @@ async function maybeEarlyWarn(stdinRaw, session) {
64
64
  const child = spawn(process.execPath, [join(HERE, "handoff-now.mjs"), projectDir, sessionId, transcript, "context-warn", windowId, tty],
65
65
  { detached: true, stdio: "ignore" });
66
66
  child.unref();
67
+ // Persistent per-window guard — the SAME one precompact uses. Without this the early-warning was
68
+ // gated ONLY by the 5-minute inflight stamp, so a session parked above the warn line re-fired every
69
+ // 5 minutes: a STORM of handoffs + a new fresh window + a new baton-close each tick (seen as 8 stacked
70
+ // handoffs ~5 min apart). markHandedOff makes alreadyHandedOff() short-circuit until the context
71
+ // actually resets (<70% of where we fired), so it's exactly ONE baton per context window.
72
+ markHandedOff(sessionId, usage.tokens);
67
73
  } catch {}
68
74
  }
69
75
 
@@ -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, openSync, readSync, fstatSync, closeSync } from "node:fs";
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, 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";
@@ -203,9 +203,31 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
203
203
  };
204
204
  const file = join(HANDOFF_DIR, `${record.id}.json`);
205
205
  writeFileSync(file, JSON.stringify(record, null, 2));
206
+ supersedeOlderHandoffs(projectName, record.id);
206
207
  return { file, record };
207
208
  }
208
209
 
210
+ // Retire any OTHER still-unconsumed handoff for the same project the moment a newer one lands. The
211
+ // fresh session loads the newest-unconsumed; leaving stale siblings around means a scrambled spawn
212
+ // (or a future session) could load an out-of-date snapshot. Marking them consumed:true + superseded
213
+ // keeps exactly one live handoff per project. Best-effort; never throws into the caller.
214
+ export function supersedeOlderHandoffs(projectName, keepId) {
215
+ try {
216
+ if (!existsSync(HANDOFF_DIR)) return;
217
+ const re = new RegExp("^" + String(projectName).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "-(\\d+)\\.json$");
218
+ for (const f of readdirSync(HANDOFF_DIR)) {
219
+ if (!re.test(f)) continue;
220
+ const p = join(HANDOFF_DIR, f);
221
+ try {
222
+ const rec = JSON.parse(readFileSync(p, "utf8"));
223
+ if (rec.id === keepId || rec.consumed) continue;
224
+ rec.consumed = true; rec.superseded = true; rec.supersededBy = keepId;
225
+ writeFileSync(p, JSON.stringify(rec, null, 2));
226
+ } catch {}
227
+ }
228
+ } catch {}
229
+ }
230
+
209
231
  // --- baton pass: the original session's Terminal window (macOS), so the fresh session can replace it ---
210
232
  // Walk the process tree to the controlling tty (the hook itself may show "??" but its parent claude
211
233
  // owns the Terminal's tty). Returns "/dev/ttysNNN" or "".
@@ -229,7 +251,12 @@ export function terminalWindowForTty(tty) {
229
251
  end repeat
230
252
  return ""
231
253
  end tell`;
232
- try { return execSync(`osascript -e ${JSON.stringify(osa)}`, { encoding: "utf8", timeout: 3000 }).trim(); } catch { return ""; }
254
+ // Pass the MULTI-LINE script via stdin, NOT `-e ${JSON.stringify(osa)}`: a single -e arg keeps the
255
+ // newlines as literal "\n" (JSON escapes them, the shell's double-quotes don't expand them), so
256
+ // osascript saw `…"Terminal"\n repeat…` and died with "27:28: Expected end of line but found unknown
257
+ // token". stdin gives it real newlines. (This silently returned "" for years → callers always fell
258
+ // through to frontTerminalWindow, which after a spawn grabs the WRONG window.)
259
+ try { return execSync(`osascript`, { input: osa, encoding: "utf8", timeout: 3000 }).trim(); } catch { return ""; }
233
260
  }
234
261
 
235
262
  // Arm the baton-close watcher: a DETACHED process that waits until the fresh session consumes the
@@ -301,14 +328,28 @@ export function frontTerminalWindow() {
301
328
  } catch { return { id: "", tty: "" }; }
302
329
  }
303
330
 
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 };
331
+ // Resolve the ORIGINAL window (id + tty) to close on takeover. Controlling tty first (if we were
332
+ // invoked with one heartbeat/precompact have it), else the CURRENT front window (a manual baton
333
+ // runs through the headless Bash tool with no tty; the session you're looking at is frontmost).
334
+ // MUST be called BEFORE spawning the fresh session — once the new window opens it becomes frontmost
335
+ // and this would capture IT instead.
336
+ export function resolveOriginalWindow() {
310
337
  let tty = controllingTty(), windowId = tty ? terminalWindowForTty(tty) : "";
311
338
  if (!windowId) { const f = frontTerminalWindow(); windowId = f.id; tty = f.tty; }
312
- const armed = windowId ? armBatonClose(handoffFile, windowId, tty, conf) : false;
339
+ return { windowId, tty };
340
+ }
341
+
342
+ // MANUAL one-command baton: spawn the fresh session (no dialog) + arm the close of THIS window once the
343
+ // fresh one consumes the handoff. Returns { spawned, armed, windowId }.
344
+ // ORDER IS LOAD-BEARING: resolve the original window BEFORE spawning. Reversing it is the
345
+ // "successor closes ITSELF" bug — the just-opened window is frontmost, the front-window fallback
346
+ // captures it, and baton-close then kills the FRESH session the moment it takes over. The seams
347
+ // (_resolveWindow/_spawnFresh/_armClose) exist so the ordering can be regression-tested headlessly.
348
+ export function spawnBaton({ projectDir, handoffFile, conf = readConfig(),
349
+ _resolveWindow = resolveOriginalWindow, _spawnFresh = spawnFresh, _armClose = armBatonClose }) {
350
+ const { windowId, tty } = _resolveWindow(); // original window FIRST, while it's still frontmost
351
+ const spawned = _spawnFresh(projectDir);
352
+ if (!spawned) return { spawned: false, armed: false, windowId: "" };
353
+ const armed = windowId ? _armClose(handoffFile, windowId, tty, conf) : false;
313
354
  return { spawned, armed, windowId };
314
355
  }
@@ -17,7 +17,7 @@ import { resolveProject, hostId } from "../lib/project.mjs";
17
17
  // takes it. A compaction-triggered SessionStart (source="compact") is the SAME session
18
18
  // that just wrote the handoff for a FRESH window to pick up — it may show the summary
19
19
  // for continuity but must NOT claim it, or it steals the handoff from the new window.
20
- function loadPendingHandoff(projectName, { claim = true } = {}) {
20
+ function loadPendingHandoff(projectName, { claim = true, freshSession = null } = {}) {
21
21
  try {
22
22
  const dir = join(homedir(), ".agent-bus", "handoffs");
23
23
  if (!existsSync(dir)) return null;
@@ -36,7 +36,23 @@ function loadPendingHandoff(projectName, { claim = true } = {}) {
36
36
  const p = join(dir, f);
37
37
  const rec = JSON.parse(readFileSync(p, "utf8"));
38
38
  if (!rec.consumed) {
39
- if (claim) { rec.consumed = true; writeFileSync(p, JSON.stringify(rec, null, 2)); }
39
+ if (claim) {
40
+ // `consumed` means "injected into THIS fresh session's first-turn context" — which happens
41
+ // here at hook time, BEFORE the model has actually read anything. So we also record WHO is
42
+ // taking over (session id + transcript path) and when. baton-close watches that transcript
43
+ // for the fresh session's first assistant turn and only then closes the original window —
44
+ // otherwise it pulled the original ~4s after the fresh window booted, "before it even read
45
+ // the handoff."
46
+ rec.consumed = true;
47
+ rec.consumedAt = nowSec();
48
+ if (freshSession && (freshSession.session_id || freshSession.transcript_path)) {
49
+ rec.consumedBy = {
50
+ session_id: freshSession.session_id || "",
51
+ transcript_path: freshSession.transcript_path || "",
52
+ };
53
+ }
54
+ writeFileSync(p, JSON.stringify(rec, null, 2));
55
+ }
40
56
  return rec;
41
57
  }
42
58
  }
@@ -44,6 +60,8 @@ function loadPendingHandoff(projectName, { claim = true } = {}) {
44
60
  return null;
45
61
  }
46
62
 
63
+ function nowSec() { try { return Number(execSync("date +%s", { encoding: "utf8" }).trim()) || 0; } catch { return 0; } }
64
+
47
65
  function relayUrl() {
48
66
  if (process.env.RELAY_URL) return process.env.RELAY_URL;
49
67
  try {
@@ -75,8 +93,8 @@ function sanitize(s) {
75
93
 
76
94
  let additionalContext = "";
77
95
  try {
78
- let source = "";
79
- try { source = (JSON.parse((await readStdin()) || "{}").source) || ""; } catch {}
96
+ let source = "", stdinObj = {};
97
+ try { stdinObj = JSON.parse((await readStdin()) || "{}"); source = stdinObj.source || ""; } catch {}
80
98
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
81
99
  // Sessions started in the home directory itself aren't project work — registering
82
100
  // them spawns a phantom "<username>" project board on the dashboard. Set
@@ -144,7 +162,10 @@ try {
144
162
  // compaction-triggered start, DON'T claim it (that's the same session that wrote it;
145
163
  // claiming would steal it from the freshly-spawned window) — show it for continuity only.
146
164
  const isCompact = source === "compact";
147
- const handoff = loadPendingHandoff(basename(projectDir), { claim: !isCompact });
165
+ const handoff = loadPendingHandoff(basename(projectDir), {
166
+ claim: !isCompact,
167
+ freshSession: { session_id: stdinObj.session_id || "", transcript_path: stdinObj.transcript_path || "" },
168
+ });
148
169
  if (handoff) {
149
170
  process.stderr.write(`[trantor] ${isCompact ? "showing (not claiming, compact)" : "loaded"} pending handoff ${handoff.id}\n`);
150
171
  additionalContext += `<trantor-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}">\n`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.24",
3
+ "version": "0.17.26",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: research
3
+ description: |
4
+ Run a real multi-source web/social research pass with the crew's economics: fan out across the
5
+ internet (Agent-Reach channels when installed — web/Jina, Exa search, GitHub, YouTube/Bilibili
6
+ transcripts, Twitter, Reddit, RSS, plus the Chinese platforms — else your own web tools), push the
7
+ cheap per-source reading to Scrooge, and synthesize a cited answer yourself. Use when the user wants
8
+ to research / deep-dive / "search the web" / "see what people say about X" / look something up across
9
+ many sources. Trigger: /trantor:research
10
+ user-invocable: true
11
+ ---
12
+
13
+ # Trantor research — fetch wide, read cheap, synthesize sharp
14
+
15
+ You are the ARCHITECT of a research pass. The shape mirrors the crew playbook: **fan out to fetch,
16
+ delegate the cheap reading, keep the synthesis.** Never burn frontier tokens scrolling raw pages.
17
+
18
+ ## 1. Scope the question
19
+ Restate the question in one line and list the **angles** that need coverage (e.g. official source,
20
+ practitioner discourse, dissent, recency). Underspecified question → ask 1–2 sharp clarifiers first.
21
+
22
+ ## 2. Pick sources (capability, not habit)
23
+ Run `agent-reach doctor --json` if available to see which backends are live, then choose per angle:
24
+ - **Semantic web search** → `mcporter call 'exa.web_search_exa(query: "…", numResults: 5)'` (free, no key)
25
+ - **Read a page/article** → `curl -s "https://r.jina.ai/<URL>"` (clean markdown, no HTML soup)
26
+ - **Code / repos** → `gh search repos "…" --sort stars` · `gh repo view owner/repo`
27
+ - **Video** → `yt-dlp --write-sub --skip-download -o /tmp/%(id)s "<URL>"` (NEVER yt-dlp for Bilibili → `bili …`)
28
+ - **Discussion** → Twitter (`twitter search`), Reddit (`opencli reddit search` / `rdt search`), V2EX
29
+ - **Chinese platforms** (often weeks ahead on AI) → Bilibili, XiaoHongShu, Zhihu, Weibo, Xueqiu via Agent-Reach
30
+ - **No Agent-Reach?** Fall back to your own WebSearch/WebFetch — the method below still holds.
31
+
32
+ If Agent-Reach isn't installed and the user wants the full reach, tell them once:
33
+ `帮我安装 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md`
34
+
35
+ ## 3. Fan out + read cheap
36
+ Collect candidate sources **in parallel** across angles. For each non-trivial source, push the
37
+ read-and-extract to a cheap model instead of reading it yourself:
38
+ `scrooge -t summarize -d easy "<extract the claims + numbers + date relevant to: …>" < source.md`
39
+ (or `relay_scrooge` from inside the crew). Many independent small reads → add `--spread N`. The point:
40
+ frontier tokens go to judgment, cheap tokens go to reading. Announce it (`scrooge watch` shows the feed).
41
+
42
+ ## 4. Verify before you trust
43
+ Cross-check any load-bearing claim against a second independent source. Flag what's a single-source
44
+ claim, what's dated, what's contested. Translate non-English sources and keep the original link.
45
+
46
+ ## 5. Synthesize (this part is yours)
47
+ Write a tight, **cited** answer: the finding, the evidence (with links + dates), the disagreements,
48
+ and the confidence. Lead with what the user didn't already know. End with the open questions a
49
+ follow-up pass should chase.