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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +35 -0
- package/bin/advise.mjs +4 -1
- package/bin/baton-close.mjs +49 -4
- package/bin/doctor.mjs +5 -1
- package/bin/profile.mjs +9 -3
- package/hooks/heartbeat.mjs +7 -1
- package/hooks/lib/handoff.mjs +50 -9
- package/hooks/sessionstart.mjs +26 -5
- package/package.json +1 -1
- package/skills/research/SKILL.md +49 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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: [
|
package/bin/baton-close.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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") {
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -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
|
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
}
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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) {
|
|
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 {
|
|
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), {
|
|
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
|
@@ -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.
|