trantor 0.17.25 → 0.17.27

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.25",
3
+ "version": "0.17.27",
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": {
@@ -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) {
@@ -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 "".
@@ -0,0 +1,104 @@
1
+ // trantor update-check — surfaces "a newer Trantor is available" the way desktop software does:
2
+ // a one-time desktop notification (macOS osascript / Linux notify-send) plus an in-session context
3
+ // block so the running model also tells the user the exact update commands.
4
+ //
5
+ // Design constraints (same contract as the other hooks): cheap, fail-silent, never blocks a session.
6
+ // • The installed version is self-discovered from the hook's OWN plugin.json (the plugin is installed at
7
+ // …/cache/trantor/trantor/<version>/…), so there's no guessing.
8
+ // • "latest" comes from the npm dist-tags endpoint — tiny + no auth — and is THROTTLED behind a TTL
9
+ // (default 6h) cached in ~/.agent-bus/update-check.json, so the vast majority of session starts do
10
+ // ZERO network. The fetch itself has a 1.5s timeout and any failure falls back to the cached value.
11
+ // • The desktop notification fires at most ONCE PER NEW VERSION (tracked by notifiedVersion), so it's
12
+ // not per-session spam — exactly one ping when a release lands, like a real updater.
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { homedir } from "node:os";
16
+ import { execSync } from "node:child_process";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url)); // <version>/hooks/lib
20
+ const DATA = process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus");
21
+ const STAMP = join(DATA, "update-check.json");
22
+ const DIST_TAGS_URL = "https://registry.npmjs.org/-/package/trantor/dist-tags";
23
+
24
+ export function readConfig() {
25
+ try { const p = join(DATA, "config.json"); return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : {}; }
26
+ catch { return {}; }
27
+ }
28
+
29
+ function nowSec() { try { return Number(execSync("date +%s", { encoding: "utf8" }).trim()) || 0; } catch { return Math.floor(Date.now() / 1000); } }
30
+ function readStamp() { try { return JSON.parse(readFileSync(STAMP, "utf8")); } catch { return {}; } }
31
+ function writeStamp(o) { try { if (!existsSync(DATA)) mkdirSync(DATA, { recursive: true }); writeFileSync(STAMP, JSON.stringify(o, null, 2)); } catch {} }
32
+
33
+ // The version of the trantor plugin THIS hook is part of (…/hooks/lib → ../../.claude-plugin/plugin.json).
34
+ // Falls back to the package.json (covers running straight from the repo, where both sit at the root).
35
+ export function installedVersion() {
36
+ for (const rel of ["../../.claude-plugin/plugin.json", "../../package.json"]) {
37
+ try { const p = join(HERE, rel); if (existsSync(p)) { const v = JSON.parse(readFileSync(p, "utf8")).version; if (v) return v; } } catch {}
38
+ }
39
+ return "";
40
+ }
41
+
42
+ // Numeric a.b.c compare → -1 | 0 | 1. (Pre-release tags aren't used by trantor's release flow, so a
43
+ // plain numeric compare is correct and keeps this dependency-free.)
44
+ export function cmpSemver(a, b) {
45
+ const pa = String(a).split(".").map(n => parseInt(n, 10) || 0);
46
+ const pb = String(b).split(".").map(n => parseInt(n, 10) || 0);
47
+ for (let i = 0; i < 3; i++) { if ((pa[i] || 0) < (pb[i] || 0)) return -1; if ((pa[i] || 0) > (pb[i] || 0)) return 1; }
48
+ return 0;
49
+ }
50
+
51
+ // Latest published version, throttled by TTL. Returns the cached value on a fresh-enough check or on any
52
+ // network failure; refetches (and re-stamps) only when the cache is stale. Never throws.
53
+ export async function latestVersion(conf = readConfig()) {
54
+ const ttlH = Number(process.env.TRANTOR_UPDATE_TTL_H || conf.updateCheckTtlHours || 6);
55
+ const stamp = readStamp();
56
+ if (stamp.latest && stamp.checkedAt && (nowSec() - stamp.checkedAt) < ttlH * 3600) return stamp.latest;
57
+ try {
58
+ const r = await fetch(DIST_TAGS_URL, { signal: AbortSignal.timeout(1500) });
59
+ const j = await r.json();
60
+ const latest = j?.latest || stamp.latest || "";
61
+ writeStamp({ ...stamp, checkedAt: nowSec(), latest });
62
+ return latest;
63
+ } catch { return stamp.latest || ""; }
64
+ }
65
+
66
+ // { available, installed, latest } — `available` true only when installed < latest. Disabled by
67
+ // TRANTOR_NO_UPDATE_CHECK=1 or config.updateCheck:false.
68
+ export async function updateAvailable(conf = readConfig()) {
69
+ if (process.env.TRANTOR_NO_UPDATE_CHECK === "1" || conf.updateCheck === false) return { available: false, installed: "", latest: "" };
70
+ const installed = installedVersion();
71
+ const latest = await latestVersion(conf);
72
+ if (!installed || !latest) return { available: false, installed, latest };
73
+ return { available: cmpSemver(installed, latest) < 0, installed, latest };
74
+ }
75
+
76
+ // Fire a native desktop notification — but only ONCE per new version (so multiple session starts don't
77
+ // each pop one). Returns true if it actually notified. Disabled by TRANTOR_NO_UPDATE_NOTIFY=1 or
78
+ // config.updateDesktopNotify:false. Best-effort; never throws.
79
+ export function maybeNotifyDesktop({ installed, latest } = {}, conf = readConfig()) {
80
+ try {
81
+ if (!latest) return false;
82
+ if (process.env.TRANTOR_NO_UPDATE_NOTIFY === "1" || conf.updateDesktopNotify === false) return false;
83
+ if (readStamp().notifiedVersion === latest) return false; // already told them about THIS version
84
+ const title = "Trantor update available";
85
+ const msg = `${installed || "?"} → ${latest}. Update: claude plugin update trantor@trantor`;
86
+ if (process.platform === "darwin") {
87
+ let done = false;
88
+ try { execSync("command -v terminal-notifier", { stdio: "ignore" });
89
+ execSync(`terminal-notifier -title ${JSON.stringify(title)} -message ${JSON.stringify(msg)} -group trantor-update`, { timeout: 3000 });
90
+ done = true;
91
+ } catch {}
92
+ if (!done) {
93
+ const osa = `display notification ${JSON.stringify(msg)} with title ${JSON.stringify(title)}`;
94
+ execSync(`osascript -e ${JSON.stringify(osa)}`, { timeout: 3000 });
95
+ }
96
+ } else if (process.platform === "linux") {
97
+ execSync(`notify-send ${JSON.stringify(title)} ${JSON.stringify(msg)}`, { timeout: 3000 });
98
+ } else {
99
+ return false;
100
+ }
101
+ writeStamp({ ...readStamp(), notifiedVersion: latest });
102
+ return true;
103
+ } catch { return false; }
104
+ }
@@ -11,13 +11,14 @@ import { join, basename } from "node:path";
11
11
  import { homedir, hostname } from "node:os";
12
12
  import { execSync } from "node:child_process";
13
13
  import { resolveProject, hostId } from "../lib/project.mjs";
14
+ import { updateAvailable, maybeNotifyDesktop } from "./lib/update-check.mjs";
14
15
 
15
16
  // Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
16
17
  // / the heartbeat early-warning). `claim` marks it consumed so exactly one session
17
18
  // takes it. A compaction-triggered SessionStart (source="compact") is the SAME session
18
19
  // that just wrote the handoff for a FRESH window to pick up — it may show the summary
19
20
  // for continuity but must NOT claim it, or it steals the handoff from the new window.
20
- function loadPendingHandoff(projectName, { claim = true } = {}) {
21
+ function loadPendingHandoff(projectName, { claim = true, freshSession = null } = {}) {
21
22
  try {
22
23
  const dir = join(homedir(), ".agent-bus", "handoffs");
23
24
  if (!existsSync(dir)) return null;
@@ -36,7 +37,23 @@ function loadPendingHandoff(projectName, { claim = true } = {}) {
36
37
  const p = join(dir, f);
37
38
  const rec = JSON.parse(readFileSync(p, "utf8"));
38
39
  if (!rec.consumed) {
39
- if (claim) { rec.consumed = true; writeFileSync(p, JSON.stringify(rec, null, 2)); }
40
+ if (claim) {
41
+ // `consumed` means "injected into THIS fresh session's first-turn context" — which happens
42
+ // here at hook time, BEFORE the model has actually read anything. So we also record WHO is
43
+ // taking over (session id + transcript path) and when. baton-close watches that transcript
44
+ // for the fresh session's first assistant turn and only then closes the original window —
45
+ // otherwise it pulled the original ~4s after the fresh window booted, "before it even read
46
+ // the handoff."
47
+ rec.consumed = true;
48
+ rec.consumedAt = nowSec();
49
+ if (freshSession && (freshSession.session_id || freshSession.transcript_path)) {
50
+ rec.consumedBy = {
51
+ session_id: freshSession.session_id || "",
52
+ transcript_path: freshSession.transcript_path || "",
53
+ };
54
+ }
55
+ writeFileSync(p, JSON.stringify(rec, null, 2));
56
+ }
40
57
  return rec;
41
58
  }
42
59
  }
@@ -44,6 +61,8 @@ function loadPendingHandoff(projectName, { claim = true } = {}) {
44
61
  return null;
45
62
  }
46
63
 
64
+ function nowSec() { try { return Number(execSync("date +%s", { encoding: "utf8" }).trim()) || 0; } catch { return 0; } }
65
+
47
66
  function relayUrl() {
48
67
  if (process.env.RELAY_URL) return process.env.RELAY_URL;
49
68
  try {
@@ -75,8 +94,8 @@ function sanitize(s) {
75
94
 
76
95
  let additionalContext = "";
77
96
  try {
78
- let source = "";
79
- try { source = (JSON.parse((await readStdin()) || "{}").source) || ""; } catch {}
97
+ let source = "", stdinObj = {};
98
+ try { stdinObj = JSON.parse((await readStdin()) || "{}"); source = stdinObj.source || ""; } catch {}
80
99
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
81
100
  // Sessions started in the home directory itself aren't project work — registering
82
101
  // them spawns a phantom "<username>" project board on the dashboard. Set
@@ -139,12 +158,30 @@ try {
139
158
  }
140
159
  } catch {}
141
160
 
161
+ // Update available? Like a desktop app's "an update is ready" — surface it two ways: a one-time
162
+ // native desktop notification (once per new version, not per session) AND an in-session context
163
+ // block so the running model can tell the user the exact update commands. Throttled + fail-silent;
164
+ // most starts do zero network (6h TTL cache). Disable: TRANTOR_NO_UPDATE_CHECK / _NOTIFY.
165
+ try {
166
+ const upd = await updateAvailable();
167
+ if (upd.available) {
168
+ maybeNotifyDesktop(upd);
169
+ additionalContext += `<trantor-update installed="${sanitize(upd.installed)}" latest="${sanitize(upd.latest)}">\n`;
170
+ additionalContext += `⬆️ **A newer Trantor is available — ${sanitize(upd.installed)} → ${sanitize(upd.latest)}.** Tell the user, and offer the update: \`claude plugin update trantor@trantor\` (plugin) + \`npm i -g trantor@${sanitize(upd.latest)}\` (CLI), then restart to apply.\n`;
171
+ additionalContext += `</trantor-update>\n`;
172
+ process.stderr.write(`[trantor] update available: ${upd.installed} -> ${upd.latest}\n`);
173
+ }
174
+ } catch {}
175
+
142
176
  // Pending handoff? A prior session hit the context limit and left a handoff for this
143
177
  // project — take over with this fresh full window instead of starting cold. On a
144
178
  // compaction-triggered start, DON'T claim it (that's the same session that wrote it;
145
179
  // claiming would steal it from the freshly-spawned window) — show it for continuity only.
146
180
  const isCompact = source === "compact";
147
- const handoff = loadPendingHandoff(basename(projectDir), { claim: !isCompact });
181
+ const handoff = loadPendingHandoff(basename(projectDir), {
182
+ claim: !isCompact,
183
+ freshSession: { session_id: stdinObj.session_id || "", transcript_path: stdinObj.transcript_path || "" },
184
+ });
148
185
  if (handoff) {
149
186
  process.stderr.write(`[trantor] ${isCompact ? "showing (not claiming, compact)" : "loaded"} pending handoff ${handoff.id}\n`);
150
187
  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.25",
3
+ "version": "0.17.27",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -10,7 +10,7 @@
10
10
  "zod": "^4.4.3"
11
11
  },
12
12
  "scripts": {
13
- "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs"
13
+ "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-update.mjs"
14
14
  },
15
15
  "description": "The hub-world for AI agent crews \u2014 orchestrate Claude Code, Codex, Gemini, Kimi & DeepSeek as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
16
16
  "files": [
@@ -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.