Pilot: a line above the Claude Code prompt with live context fill and the project's handoff state, read from Trantor's files. Off unless TRANTOR_MOD=1.

████████╗██████╗ █████╗ ███╗ ██╗████████╗ ██████╗ ██████╗
╚══██╔══╝██╔══██╗██╔══██╗████╗ ██║╚══██╔══╝██╔═══██╗██╔══██╗
██║ ██████╔╝███████║██╔██╗ ██║ ██║ ██║ ██║██████╔╝
██║ ██╔══██╗██╔══██║██║╚██╗██║ ██║ ██║ ██║██╔══██╗
██║ ██║ ██║██║ ██║██║ ╚████║ ██║ ╚██████╔╝██║ ██║
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
**One Advisor decides how your work runs — solo, cheap inline calls, or a live crew of Claude Code, Codex, GLM, Kimi, DeepSeek — or any model you bring — in their own terminal windows, routed by your actual plans, supervised on a live + historical board you can scroll back through, learning from every failure.**
npm install -g trantor
trantor setup # hub becomes an always-on service + config + wires your AI CLIs + doctor
Then give Claude Code (the orchestrator) the plugin:
claude plugin marketplace add sashabogi/trantor
claude plugin install trantor
Kimi Code CLI as orchestrator (same capabilities — the relay_* MCP tools, the crew/handoff/ research skills, and the full hook set: session registration, focus cards, todo mirroring, heartbeats, inbox delivery, handoff/baton pass, sub-agent cards):
~/.kimi-code/mcp.json: {
"mcpServers": {
"relay": {
"command": "node",
"args": ["<absolute-path-to-trantor>/mcp.mjs"],
"env": { "RELAY_AGENT": "kimi-orch" },
"startupTimeoutMs": 15000,
"toolTimeoutMs": 150000
}
}
}
/plugins install <absolute-path-to-trantor> (or the GitHub URL), then /reload, then start a new session.Since 0.17.76 the Kimi hooks are a dialect bridge, not a fork: kimi/bridge.mjs runs the canonical hooks as children and translates Kimi's input/output conventions at the edges (SessionStart context is stashed and delivered on the first prompt). Kimi hooks therefore sign their hub calls, follow per-project hub pins, and inherit every future hook fix automatically. Do not set RELAY_URL in MCP configs — a hardcoded URL overrides the per-project pin and splits the project across hubs; the pin decides the hub.
Notes: Kimi plugin installs are snapshots (~/.kimi-code/plugins/managed/trantor/) — after updating trantor, re-run /plugins install to refresh the skills/hooks. The MCP entry above always runs your live checkout, so the relay server itself never goes stale. Invoke the skills with /skill:crew, /skill:handoff, /skill:research. Set TRANTOR_DEBUG_HOOKS=1 on the kimi process to dump raw hook payloads to ~/.agent-bus/kimi-hook-debug.jsonl.
The orchestrator's bus identity is kimi-orch:<project> — deliberately distinct from kimi:<project>, which belongs to a kimi CREW SEAT (trantor up kimi). Same doctrine as the openrouter seat label: one bus peer per role, so an orchestrator and its own kimi seat never share a heartbeat, inbox, or card attribution.
That's it. (Prefer source? git clone https://github.com/sashabogi/trantor && cd trantor && npm install && bash deploy/setup.sh — identical result.)
Recommended companion (macOS): cmux — brew install --cask cmux — a native terminal built for running multiple AI agents. Trantor groups each crew into one cmux workspace with live per-seat status in the sidebar and project-scoped teardown. See Grouped crews in cmux. Optional — crews also run in tmux or plain Terminal windows.
Trantor is a local-first multi-agent orchestrator with a built-in cost router — not a cloud service, and not an agent that runs off on its own. Here is exactly what the two steps above put on your machine, so you (or an agent installing it for you) can see the whole footprint up front:
npm install -g trantor + trantor setup
trantor CLI — one global npm package.~/.agent-bus/ — a single local directory holding all state: config.json, the board data (bus.json), and .env for any provider API keys you choose to add (e.g. DEEPSEEK_API_KEY). Nothing in here ever leaves your machine.http://127.0.0.1:4477 — loopback only, not reachable from the network. On macOS it's a launchd agent (com.trantor.hub) so it restarts at login; on Linux you run it yourself.~/.local/bin — the cost ledger and cheap-model router.claude plugin install trantor adds, inside Claude Code only:
relay) exposing the relay_* tools, plus the /trantor:* skills.**What it does not do:** no cloud, no accounts, no telemetry, nothing phones home; it never uploads your code or keys; it doesn't touch other CLIs' credentials — Codex, Kimi, DeepSeek, GLM (and any provider you bring) are ones you already installed and signed into, and Trantor just coordinates them locally. The optional API keys in ~/.agent-bus/.env are used only to call the models you opted into for routing.
Remove everything, anytime:
claude plugin uninstall trantor # drop the MCP tools, skills, and hooks
launchctl bootout gui/$(id -u)/com.trantor.hub # stop the hub service (macOS)
rm -f ~/Library/LaunchAgents/com.trantor.hub.plist
rm -rf ~/.agent-bus # delete all local state + keys
trantor setup ends with the doctor — an honest map of where you stand:
TRANTOR DOCTOR
core
✓ node 22.x
✓ hub up at http://127.0.0.1:4477
claude (the orchestrator)
✗ plugin not installed
→ claude plugin marketplace add sashabogi/trantor && claude plugin install trantor
crew CLIs (install any subset — seats follow the work)
✓ codex: wired to the bus
✗ codex: NOT authenticated — it will join the bus but fail on its first turn
→ codex (sign in with your ChatGPT account on first run)
– kimi: not installed (optional)
the brain
✗ quota profile not set → trantor profile set claude=max codex=plus deepseek=api
Fix the → lines (each CLI's own sign-in happens once, in that CLI) and re-run trantor doctor until it's clean.
Provider API keys (e.g. DEEPSEEK_API_KEY) start in one file, ~/.agent-bus/.env, and on macOS they belong in the keychain: trantor secrets migrate moves every key there and leaves a # NAME -> keychain stub in its place, trantor secrets list shows which layer holds each key (never a value), and printf '%s' "$KEY" | trantor secrets set NAME adds one. The crew runner reads the store at every turn and hands the keys to the seat in its environment, so nothing is copied to disk; a key still in .env keeps working as the fallback, and trantor doctor names it. The store wins over .env, which wins over anything Scrooge has.
That precedence is the point. Scrooge (the cheap-model router) keeps its own keys in ~/.token-scrooge/.env, and if the crew has no key of its own it falls through to Scrooge's. That still works, but then one key authenticates both and your provider bill cannot tell them apart — a crew seat and a batch of grunt summaries land on the same line item. Give the crew separate keys, minted in the provider console rather than copied, and each shows up on its own line and can be capped independently. trantor doctor reports which key each surface resolves to, masked, under "provider keys".
Open Claude Code in the project you want built and say it in plain words:
fire up the crew — build me a 2-player asteroids game with power-ups
Any phrasing works ("build it with the crew", "build this with trantor"), or invoke the skill directly: /trantor:crew. Claude becomes the architect: it cuts the work into difficulty-tagged packages, asks the Advisor, and shows you the routing table with a real-money estimate before spending anything. You say go — terminal windows open, the board fills, and you watch it live:
trantor ui
No crew CLIs installed yet? It still works — the Advisor routes the work solo or to cheap inline scrooge calls instead of seats. Seats follow the work and what's actually installed.
Running low on context mid-build? Say /trantor:handoff — a fresh session in the same project takes over with a full window (and a PreCompact hook does this automatically).
relay_advise, and shows you the full picture before spending anything: mode (solo | scrooge | crew | hybrid), a routing table with a reason per package, why that many seats ("seats follow the work, not the install list"), and a real-money estimate with quota-pool accounting. You say go.trantor up codex kimi deepseek:deepseek glm:zai-coding-plan spawns the crew in visible terminals — grouped into one cmux workspace per project when cmux is installed (seats tiled, live status in the sidebar; see Grouped crews in cmux below), else tmux, else one titled Terminal window per seat. The seats aren't a fixed list — they're whatever you've got: the native CLIs (Codex, Kimi) plus any provider wired through OpenCode (DeepSeek, GLM, and OpenRouter's hundreds of models, or a custom endpoint you bring — see Bring your own model below). agent:model pins a model; agent:provider --difficulty hard picks the best live model for the work at spawn (capability × cost), enumerated from the provider itself — never a guessed endpoint. Serialized and then verified on the bus — the launcher ends with "crew verified" or names the no-shows loudly. The orchestrator never gets a green lie. Seats build in their own git worktrees (~/.agent-bus/worktrees/<project>/<seat>), and a fresh checkout lacks gitignored files and cannot resolve relative sibling packages. Declare what a worktree needs in .trantor/worktree.json: {"link": ["../sibling-repo"], "provision": [{"path": "ios/Config.swift", "mode": "link|stub|operator"}], "preflight": "npm test --silent"}. trantor up links the siblings beside the worktree, links or stubs the declared files (a real credential is never copied; operator entries are named for you), runs the preflight once in the first fresh worktree (5-minute cap) and prints preflight ok or the last 20 lines — broadcast on the bus too, so the orchestrator sees it before writing contracts. No declaration, no preflight.todo → doing → testing → done — testing is a real gate (tests/typecheck run there); failures turn the card pulsing red until the orchestrator bounces them back; demoted cards wear an "↩ bounced" mark with full history.trantor swap <old> <new> tears down an exhausted agent and spawns a live-selected replacement, ready for a fresh contract.relay_lesson), stored on the hub and injected into every future crew's prompts — global or per-CLI. Your crew gets smarter every run.Trantor runs crews in visible terminals so nothing dies or fails silently. On macOS it prefers cmux — a native, Ghostty-based terminal built for managing multiple AI coding agents — and falls back to tmux, then plain Terminal windows.
With cmux, each crew becomes one workspace tab per project, its seats tiled inside, every pane labeled <agent> · <project>, and each seat pushes its live state into cmux's sidebar — building (blue) while a turn runs, idle when it's waiting, error/down (red) on a failed turn — plus a per-crew progress pill. Teardown is project-scoped: trantor down closes only this project's workspace, so when you run several sessions each driving its own crew, one session's teardown can't nuke another's. trantor down <agent> drops a single seat; trantor down --all --yes tears down every project's crew.
Since 0.18.20, seat trouble wakes the foreman instead of hoping someone looks: a failing or dead seat direct-messages the project's orchestrator (broadcasts wake nobody), a detached watchdog reports a turn running silent past 15 minutes — once, without killing it — and the failure classifier tells a provider backend error ("retry or swap") from real quota exhaustion ("wait the window out"). The duty seat itself now runs under a launchd keepalive, so the fleet's janitor relaunches after a crash or reboot instead of dying silently, and the hub routes escalations back to their senders whenever the janitor goes dark.
On macOS, trantor duty up starts both keepalives (duty seat + local socket wakes); trantor duty status reports both and trantor duty down stops both. Wake latency is the hub's 2-minute UNDELIVERED threshold + a 5-second incremental poll + socket/hook time; the shared ledger's first claim wins, and unreachable sessions fall through to duty.
One-time setup:
brew install --cask cmux (or grab it from cmux.com).~/.config/cmux/cmux.json (cmux auto-reloads): ``json { "automation": { "socketControlMode": "allowAll" } } ` This lets local processes drive your terminals. Leave it on the default cmuxOnly` and Trantor automatically falls back to AppleScript (same grouped layout, minus the native sidebar status).cmux CLI is symlinked onto your PATH on your first trantor up.No cmux or tmux? Crews still work — one titled Terminal window per seat, still project-scoped teardown.
trantor uiA live command center at http://127.0.0.1:4477, grouped by project — and a durable, self-maintaining record, not just a snapshot. Dead sessions self-prune (no more graveyard of stale boards), and the project order is stable: a working board updates in place instead of jumping to the top while you're reading it.
Three views per project (your choice sticks):
Plus:
Every session registers automatically — crew or not — and a solo session's own todo list shows up on the board as cards, so the dashboard reflects all the work on a project, not just crew runs.
Cards carry their story on the card: every note lands in a permanent per-card log (last 40 entries) that survives restarts, retention, and id remaps — the drawer shows it as the card's narrative, so clicking an old card tells you what actually happened, not a one-line status trail. The todo lane has a lifecycle: cards untouched for 14 days (RELAY_TODO_STALE_MS) move to STALE with an "aged out" note instead of rotting silently, and todo tiles wear an age badge from day 7. A commit closes the session's focus card and the two link both ways. trantor doctor cross-checks every hub you know about against the per-project pins and reports any split-brain (a project live on two hubs) with the exact fix — and trantor adopt <project> migrates a project between hubs in one verified step, telling stale sessions to restart. A project's identity lives in its checkout (.trantor/project.json, written by trantor new, trantor connect or trantor project <id>), so renaming the directory keeps the board, the pin and the sessions; the doctor names an orphaned identity and the one command that reclaims it.
Crew output is gated mechanically, too: bin/slop-gate.mjs runs the vendored anti-slop Oxlint rules over an agent's changed files only — unexplained type assertions, unknown laundering, runtime typeof, Reflect tricks and friends fail the card before it can reach done, while legacy debt burns down on its own card instead of blocking everyone. A surface that has paid its debt off stays paid: desktop/src is at zero and npm test now lints it in full (node bin/slop-gate.mjs --surface desktop/src).
Trantor's economics engine (Scrooge — installed automatically by trantor setup) knows model capabilities, per-1M costs, and keeps the ledger; Trantor turns that into decisions:
trantor profile set claude=max codex=plus zai=coding-plan kimi=coding-plan deepseek=apirelay_scrooge): the architect and crew members push stateless grunt work to cheap models, with ledger receipts.The crew roster isn't a hardcoded list — it's derived from what you've configured. Trantor ships built-in seats for Codex and Kimi (native CLIs) and DeepSeek + GLM (via OpenCode), but any provider OpenCode can reach becomes a first-class seat with its own identity on the board — no code change, no waiting for us to add it.
OPENROUTER_API_KEY in ~/.agent-bus/.env and openrouter is a seat: trantor up openrouter live-selects the best model for the work, or trantor up openrouter:openrouter/<vendor>/<model> pins one.bash trantor provider add <name> --key sk-… --plan api # a provider OpenCode already knows trantor provider add acme --key sk-… --base-url https://api.acme.ai/v1 --models acme-lg,acme-sm ` The second form wires a custom OpenAI-compatible endpoint into OpenCode for you (no hand-editing config). Then trantor provider lists every seat with its availability + tier, and trantor models [<provider>]` browses the live models behind a seat — including the router's pick per difficulty so you can see hard → strong, easy → cheap at a glance.scrooge-capabilities scores a provider's whole catalog (real Artificial-Analysis benchmarks where a model matches, a price-tier proxy for the long tail) and the router weighs capability × cost, gated by the work's difficulty — so hard work escalates to a genuinely strong model while easy work stays cheap. Run it once (a weekly cron keeps it fresh): scrooge-capabilities.glm:<project>, openrouter:<project>, acme:<project>), so they never collide and each shows up as its own agent on the dashboard.Brought a key for Inception, a new Japanese model, a niche fine-tune?
trantor provider addit,scrooge-capabilitiesscores it, and the Advisor routes to it by difficulty — same as the built-ins.
A PreCompact hook writes a rich handoff before Claude Code compacts; a fresh session in the same project takes over with a brand-new full context window. Works manually from any agent via relay_handoff. Optional macOS auto-prompt (autoHandoffPrompt in ~/.agent-bus/config.json) offers to open the fresh session for you, with a timeout.
Since 0.18.18 the succession is a machine, not a ritual: at 90% context the running agent is told to finish or checkpoint and author the boundary handoff itself; the app's banner counts down ("handing off in 10s") when the dial allows; an automatic digest defers to a fresh model-authored handoff instead of superseding it; the record on disk keeps the author's summary whole and the ~4KB budget applies only to what is injected, where the cut is section-aware so the successor's open threads survive it (0.18.68 — before that the cut ran at write time and ate the middle, which is exactly where a work order sits); the successor gets a kickoff prompt so it recaps without being spoken to; and a session hosted in a Workspace pane is replaced in place by a detached driver — the same chain the app's [Hand off now] button runs.
Waking works the same way from the other end: hover a sleeping project in the app's sidebar and Wake hosts its session as an in-app pane — trantor open under the hood, opened in the project's own checkout wherever it is called from, handoff-beats-resume, and the same kickoff prompt so the woken session catches up (handoff, board, memory) and recaps on its own. A reboot can leave herdr with a pane in its layout and no terminal behind it; trantor open, the app's Resume
hooks/trantor-band.tsx 84 lines1// Read-only pilot band (#12091): "ctx 34% · handoff pending" above the prompt, OFF unless TRANTOR_MOD=1.
2// It never calls the hub (no auth surface) and any error draws nothing.
3// The one write is ~/.agent-bus/mod-context/<session>.json.
4import type { EngineInterface, Register } from 'claude-code'
5
6import {
7 bandText, basename, contextPercent, contextRecord, handoffState, newestHandoffName, projectFromMarker, safeId,
8} from './band-lib.mjs'
9
10type Snapshot = { percent: number | null; handoff: string; at: number }
11
12const REFRESH_MS = 15_000
13
14// A reload starts these over; they are only a cache of what the files and the engine can say again.
15let snap: Snapshot = { percent: null, handoff: '', at: 0 }
16
17// AGENT_BUS_DIR mirrors hooks/lib/handoff.mjs; HOME is the fallback.
18async function busDir($: EngineInterface): Promise<string> {
19 const override = await $.env.get('AGENT_BUS_DIR')
20 if (override) return override
21 const home = await $.env.get('HOME')
22 return home ? `${home}/.agent-bus` : ''
23}
24
25async function readHandoff($: EngineInterface, bus: string): Promise<string> {
26 const root = await $.session.root()
27 let project = ''
28 try { project = projectFromMarker(await $.fs.read(`${root}/.trantor/project.json`)) } catch {}
29 if (!project) project = basename(root)
30 const dir = `${bus}/handoffs`
31 const entries = await $.fs.list(dir)
32 const name = newestHandoffName(entries.map(entry => entry.name), project)
33 if (!name) return ''
34 return handoffState(await $.fs.read(`${dir}/${name}`), Math.floor((await $.clock.now()) / 1000))
35}
36
37// One refresh: the live context from the native API, the handoff file, and the context JSON.
38async function refresh($: EngineInterface, writeContext: boolean): Promise<void> {
39 try {
40 if ((await $.env.get('TRANTOR_MOD')) !== '1') return
41 const bus = await busDir($)
42 const usage = await $.session.usage()
43 const now = await $.clock.now()
44 let handoff = ''
45 try { if (bus) handoff = await readHandoff($, bus) } catch {}
46 snap = { percent: contextPercent(usage.context), handoff, at: now }
47 if (writeContext && bus) {
48 const record = contextRecord(usage.context, now)
49 const id = safeId(await $.session.id())
50 if (record && id) await $.fs.write(`${bus}/mod-context/${id}.json`, JSON.stringify(record))
51 }
52 } catch {}
53}
54
55export const register: Register = on => {
56 on('session.start', async ($, e, next) => {
57 await refresh($, false)
58 return next(e)
59 })
60
61 on('turn.complete', async ($, e, next) => {
62 await refresh($, true)
63 try { $.ui.invalidate('ui.render') } catch {}
64 return next(e)
65 })
66
67 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68 try {
69 if ((await $.env.get('TRANTOR_MOD')) !== '1' || e.props.hasSurvey) return next(e)
70 if ((await $.clock.now()) - snap.at > REFRESH_MS) await refresh($, false)
71 const text = bandText(snap)
72 if (!text) return next(e)
73 const { Box, Text } = $.ui.resolve(e)
74 return (
75 <Box>
76 <Text dimColor>{text}</Text>
77 </Box>
78 )
79 } catch {
80 return next(e)
81 }
82 })
83}
84hooks/band-lib.mjs 73 lines1// Pure helpers for the trantor-band pilot mod (#12091). No I/O and no Node imports on purpose:
2// the mod runs in Claude Code's sandboxed module environment, and the tests import this file directly.
3
4// The session id comes from Claude Code, but it names a file, so keep it to a safe alphabet.
5export function safeId(id) {
6 return String(id ?? "").replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
7}
8
9// Context fill as a fraction, from $.session.usage().context. Null until the first API response.
10export function contextFraction(context) {
11 const tokens = Number(context?.tokens);
12 const window = Number(context?.window);
13 if (context?.tokens == null || !Number.isFinite(tokens) || !Number.isFinite(window) || window <= 0 || tokens < 0) return null;
14 return Math.min(1, tokens / window);
15}
16
17export function contextPercent(context) {
18 const frac = contextFraction(context);
19 return frac === null ? null : Math.round(frac * 100);
20}
21
22// The file the pilot leaves for Trantor's own code to read later.
23export function contextRecord(context, ts) {
24 const frac = contextFraction(context);
25 if (frac === null) return null;
26 return { ts, tokens: Number(context.tokens), window: Number(context.window), frac: Math.round(frac * 10000) / 10000 };
27}
28
29// The project id a checkout records in .trantor/project.json, mirroring lib/project.mjs readProjectId.
30export function projectFromMarker(text) {
31 try {
32 const id = JSON.parse(text)?.id;
33 return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(String(id ?? "")) && !String(id).includes("..") ? String(id) : "";
34 } catch { return ""; }
35}
36
37export function basename(path) {
38 return String(path ?? "").replace(/\/+$/, "").split("/").pop() || "";
39}
40
41// The newest <project>-<unix seconds>.json among a directory listing, or "" (the name handoff.mjs writes).
42export function newestHandoffName(names, project) {
43 if (!project) return "";
44 const prefix = `${project}-`;
45 let best = "";
46 let bestTs = -1;
47 for (const name of names || []) {
48 if (!name.startsWith(prefix)) continue;
49 const rest = name.slice(prefix.length);
50 if (!/^\d+\.json$/.test(rest)) continue;
51 const ts = Number(rest.slice(0, -5));
52 if (ts > bestTs) { bestTs = ts; best = name; }
53 }
54 return best;
55}
56
57// "pending" (written, nobody has it), "claimed" (a fresh session holds an unexpired claim), or ""
58// when it is consumed or unreadable. Mirrors hooks/lib/handoff-claims.mjs: consumed, claim.expiresAt.
59export function handoffState(text, nowSeconds) {
60 let record;
61 try { record = JSON.parse(text); } catch { return ""; }
62 if (!record || record.consumed) return "";
63 return Number(record.claim?.expiresAt) > nowSeconds ? "claimed" : "pending";
64}
65
66// The band's one line. Parts that are unknown are left out; an empty result means draw nothing.
67export function bandText({ percent, handoff }) {
68 const parts = [];
69 if (Number.isFinite(percent)) parts.push(`ctx ${percent}%`);
70 if (handoff) parts.push(`handoff ${handoff}`);
71 return parts.join(" · ");
72}
73