trantor 0.17.47 → 0.17.49

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.47"
9
+ "version": "0.17.49"
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, GLM, 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.47",
16
+ "version": "0.17.49",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.47",
3
+ "version": "0.17.49",
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
@@ -12,14 +12,14 @@
12
12
  ### The hub-world for AI agent crews.
13
13
 
14
14
  **One Advisor decides how your work runs — solo, cheap inline calls, or a live crew of
15
- Claude Code, Codex, GLM, Kimi & DeepSeek in their own terminal windows — routed by your
16
- actual plans, supervised on a live + historical board you can scroll back through, learning
17
- from every failure.**
15
+ Claude Code, Codex, GLM, Kimi, DeepSeek — or *any model you bring* — in their own terminal
16
+ windows, routed by your actual plans, supervised on a live + historical board you can scroll
17
+ back through, learning from every failure.**
18
18
 
19
19
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
20
20
  ![Node](https://img.shields.io/badge/node-%E2%89%A518-339933?logo=node.js&logoColor=white)
21
- ![Agents](https://img.shields.io/badge/crew-Claude%20%C2%B7%20Codex%20%C2%B7%20Gemini%20%C2%B7%20Kimi%20%C2%B7%20DeepSeek-D97757)
22
- ![Tests](https://img.shields.io/badge/tests-80%2F80-2DD4BF)
21
+ ![Agents](https://img.shields.io/badge/crew-Claude%20%C2%B7%20Codex%20%C2%B7%20GLM%20%C2%B7%20Kimi%20%C2%B7%20DeepSeek%20%C2%B7%20%2BBYOM-D97757)
22
+ ![Tests](https://img.shields.io/badge/tests-passing-2DD4BF)
23
23
 
24
24
  </div>
25
25
 
@@ -65,9 +65,10 @@ on your machine, so you (or an agent installing it for you) can see the whole fo
65
65
  **SubagentStop** (record each sub-agent's notional cost on the board).
66
66
 
67
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.
68
+ your code or keys; it doesn't touch other CLIs' credentials — Codex, Kimi, DeepSeek, GLM (and any
69
+ provider you bring) are ones *you* already installed and signed into, and Trantor just coordinates
70
+ them locally. The optional API keys in `~/.agent-bus/.env` are used only to call the models *you*
71
+ opted into for routing.
71
72
 
72
73
  **Remove everything, anytime:**
73
74
  ```bash
@@ -134,12 +135,14 @@ project takes over with a full window (and a PreCompact hook does this automatic
134
135
  (`solo | scrooge | crew | hybrid`), a routing table with a **reason per package**, why
135
136
  that many seats ("seats follow the work, not the install list"), and a real-money estimate
136
137
  with quota-pool accounting. You say go.
137
- 2. **Windows open.** `trantor up codex kimi deepseek:deepseek opencode:zai-coding-plan` spawns one titled
138
- terminal window per agent. `agent:model` pins a model; `agent:provider --difficulty hard`
139
- picks the **best live model** for the work at spawn (capability × cost), enumerated from the
140
- CLI itself — never a guessed endpoint. **Serialized and then verified on the bus** — the
141
- launcher ends with "crew verified" or names the no-shows loudly. The orchestrator never gets
142
- a green lie.
138
+ 2. **Windows open.** `trantor up codex kimi deepseek:deepseek glm:zai-coding-plan` spawns one titled
139
+ terminal window per agent. The seats aren't a fixed list — they're **whatever you've got**:
140
+ the native CLIs (Codex, Kimi) plus *any* provider wired through OpenCode (DeepSeek, GLM, and
141
+ **OpenRouter's hundreds of models, or a custom endpoint you bring** — see *Bring your own
142
+ model* below). `agent:model` pins a model; `agent:provider --difficulty hard` picks the
143
+ **best live model** for the work at spawn (capability × cost), enumerated from the provider
144
+ itself — never a guessed endpoint. **Serialized and then verified on the bus** — the launcher
145
+ ends with "crew verified" or names the no-shows loudly. The orchestrator never gets a green lie.
143
146
  3. **Work flows over the bus.** Contracts arrive as messages; each agent owns its own files;
144
147
  coordination happens in <280-char messages you can read on the dashboard. Crew members
145
148
  live under a **runner**: the CLI works one turn and exits, the runner long-polls the bus
@@ -202,6 +205,39 @@ ledger; Trantor turns that into decisions:
202
205
  - **Fractal delegation** (`relay_scrooge`): the architect *and* crew members push stateless
203
206
  grunt work to cheap models, with ledger receipts.
204
207
 
208
+ ## Bring your own model (BYOM)
209
+
210
+ The crew roster isn't a hardcoded list — it's **derived from what you've configured**. Trantor
211
+ ships built-in seats for Codex and Kimi (native CLIs) and DeepSeek + GLM (via OpenCode), but
212
+ **any** provider OpenCode can reach becomes a first-class seat with its own identity on the
213
+ board — no code change, no waiting for us to add it.
214
+
215
+ - **OpenRouter is the on-ramp.** One key fronts **hundreds of models** (incl. vendors that have
216
+ no CLI of their own). Drop `OPENROUTER_API_KEY` in `~/.agent-bus/.env` and `openrouter` is a
217
+ seat: `trantor up openrouter` live-selects the best model for the work, or
218
+ `trantor up openrouter:openrouter/<vendor>/<model>` pins one.
219
+ - **Add any provider in one command:**
220
+ ```bash
221
+ trantor provider add <name> --key sk-… --plan api # a provider OpenCode already knows
222
+ trantor provider add acme --key sk-… --base-url https://api.acme.ai/v1 --models acme-lg,acme-sm
223
+ ```
224
+ The second form wires a **custom OpenAI-compatible endpoint** into OpenCode for you (no
225
+ hand-editing config). Then `trantor provider` lists every seat with its availability + tier, and
226
+ `trantor models [<provider>]` browses the live models behind a seat — including the router's pick
227
+ per difficulty so you can see *hard → strong, easy → cheap* at a glance.
228
+ - **It routes by difficulty, not just price.** `scrooge-capabilities` scores a provider's whole
229
+ catalog (real Artificial-Analysis benchmarks where a model matches, a price-tier proxy for the
230
+ long tail) and the router weighs **capability × cost, gated by the work's difficulty** — so hard
231
+ work escalates to a genuinely strong model while easy work stays cheap. Run it once (a weekly
232
+ cron keeps it fresh): `scrooge-capabilities`.
233
+ - **Every seat is its own colleague.** Each opencode-driven provider gets a distinct bus label
234
+ (`glm:<project>`, `openrouter:<project>`, `acme:<project>`), so they never collide and each shows
235
+ up as its own agent on the dashboard.
236
+
237
+ > Brought a key for Inception, a new Japanese model, a niche fine-tune? `trantor provider add` it,
238
+ > `scrooge-capabilities` scores it, and the Advisor routes to it by difficulty — same as the
239
+ > built-ins.
240
+
205
241
  ## Context handoff — sessions that never hit the wall
206
242
 
207
243
  A PreCompact hook writes a rich handoff before Claude Code compacts; a fresh session in the
@@ -231,29 +267,41 @@ rate, not work rate.
231
267
  ## The CLI
232
268
 
233
269
  ```
234
- trantor setup | doctor | connect | profile | up <agents…> | swap <old> <new> | down | ui | advise | hub | watch
270
+ trantor setup | doctor | connect | profile | provider | models
271
+ | up <agents…> | swap <old> <new> | down | ui | advise | hub | watch
235
272
  ```
236
273
 
237
- `trantor up` notes: `agent:model` pins a model (`deepseek:deepseek-v4-pro`); `agent:provider
238
- --task <k> --difficulty <d>` picks the **best live model** for the work at spawn
239
- (`opencode:zai-coding-plan --difficulty hard`); spawns are verified on the bus with one retry;
240
- geometry auto-detects the screen you're working on (`CREW_RECT="X,Y,W,H"` to override). `trantor
241
- swap <oldAgent> <newSpec>` replaces an exhausted agent with a live-selected one. `trantor down`
242
- kills crew processes via their ttys and closes windows without macOS "Terminate?" dialogs.
274
+ - **`trantor provider`** — `list` every crew seat (built-in + brought) with availability + tier ·
275
+ `add <name> --key … [--plan api] [--base-url <url> --models a,b]` to bring any provider (custom
276
+ endpoints are wired into OpenCode for you) · `remove <name>`.
277
+ - **`trantor models [<provider>]`** — browse the live models behind each seat + the router's pick
278
+ per difficulty.
279
+ - **`trantor up`** — `agent:model` pins a model (`deepseek:deepseek-v4-pro`); `agent:provider
280
+ --task <k> --difficulty <d>` picks the **best live model** for the work at spawn
281
+ (`glm:zai-coding-plan --difficulty hard`); spawns are verified on the bus with one retry;
282
+ geometry auto-detects the screen you're working on (`CREW_RECT="X,Y,W,H"` to override).
283
+ - **`trantor swap <oldAgent> <newSpec>`** replaces an exhausted agent with a live-selected one.
284
+ - **`trantor down`** kills crew processes via their ttys and closes windows without macOS
285
+ "Terminate?" dialogs.
243
286
 
244
287
  ## Works with any MCP agent
245
288
 
246
- Claude Code, Codex CLI, Gemini CLI, Kimi Code CLI, OpenCode (DeepSeek) are wired by
247
- `trantor connect` automatically (idempotent, backed-up, never overwrites your customizations).
248
- Anything else that speaks MCP: point it at `mcp.mjs` with `RELAY_AGENT=<brand>` — loading the
249
- server auto-registers the session, so presence works before the model says a word.
289
+ Claude Code, Codex CLI, Kimi Code CLI, and **OpenCode** (the universal adapter — DeepSeek, GLM,
290
+ OpenRouter, and any provider you bring) are wired by `trantor connect` automatically (idempotent,
291
+ backed-up, never overwrites your customizations). Anything else that speaks MCP: point it at
292
+ `mcp.mjs` with `RELAY_AGENT=<brand>` — loading the server auto-registers the session, so presence
293
+ works before the model says a word.
294
+
295
+ *(Gemini CLI: Google retired the free seat in 2026, so it's no longer a default crew seat —
296
+ GLM via OpenCode is its replacement. Gemini still works as a Scrooge cheap-model via
297
+ `GEMINI_API_KEY`, and a paid-key holder can still run `trantor up gemini`.)*
250
298
 
251
299
  ## How it works
252
300
 
253
301
  ```
254
- Claude (architect/plugin) codex ─ runner gemini ─ runner kimi ─ runner deepseek ─ runner
302
+ Claude (architect/plugin) codex ─ runner kimi ─ runner opencode ─ runner (DeepSeek·GLM·OpenRouter·BYOM)
255
303
  │ advise/contracts │ one turn, exit; runner long-polls (free) + resumes with context
256
- └───────────┬─────────────┴──────────────┴────────────────┴────────────────┘
304
+ └───────────┬─────────────┴──────────────┴───────────────────────────────────┘
257
305
  ▼
258
306
  hub.mjs ←— plain HTTP + SSE · presence/messages/board/history/lessons/learning/economics
259
307
  (Node built-ins only · state in ~/.agent-bus/bus.json · loopback by default)
@@ -286,10 +334,11 @@ The `relay_*` tool names and the `~/.agent-bus` state dir remain until a later r
286
334
  ## Tests
287
335
 
288
336
  ```bash
289
- npm test # 80 checks: unit + protocol-level scenario drills with mock agents (no LLMs, seconds, $0):
290
- # honest presence + TTL prune, spawn no-shows, the testing gate, bounce trails, lessons,
291
- # /history + backfill, /learning shape, /todos sync, /card detail, advisor decisions across
292
- # plan tiers, deps validation, virgin-machine doctor, and failure-classification drills
337
+ npm test # unit + 120+ protocol-level scenario drills + capability-routing, all with mock agents
338
+ # (no LLMs, seconds, $0): honest presence + TTL prune, spawn no-shows, the testing gate,
339
+ # bounce trails, lessons, /history + backfill, /learning shape, /todos sync, /card detail,
340
+ # advisor decisions across plan tiers, BYOM roster derivation + brought-provider routing,
341
+ # difficulty-aware model selection, deps validation, virgin-machine doctor, failure drills
293
342
  ```
294
343
 
295
344
  ## License
package/bin/advise.mjs CHANGED
@@ -36,7 +36,7 @@ export const BUILTIN_ROSTER = {
36
36
  codex: { cli: "codex", launch: "codex", session: "codex", provider: "codex" },
37
37
  kimi: { cli: "kimi", launch: "kimi", session: "kimi", provider: "kimi" },
38
38
  deepseek: { cli: "opencode", launch: "deepseek:deepseek", session: "deepseek", provider: "deepseek", providerOc: "deepseek" },
39
- glm: { cli: "opencode", launch: "opencode:zai-coding-plan", session: "opencode", provider: "zai", providerOc: "zai-coding-plan" },
39
+ glm: { cli: "opencode", launch: "glm:zai-coding-plan", session: "glm", provider: "zai", providerOc: "zai-coding-plan" },
40
40
  openrouter: { cli: "opencode", launch: "openrouter:openrouter", session: "openrouter", provider: "openrouter", providerOc: "openrouter" },
41
41
  };
42
42
  // opencode provider ids already claimed by a built-in (so discovery never duplicates them) + the
@@ -193,12 +193,12 @@ export function advise(input, world = loadWorld()) {
193
193
  const seat = roster[r.executor];
194
194
  return {
195
195
  order: i + 1, title: r.title, difficulty: r.difficulty,
196
- // bus identity = the runner's session name (spec's agent part) — glm rides the `opencode`
197
- // runner so its session/assignee is `opencode:<project>`, NOT `glm:<project>`.
196
+ // bus identity = the seat's session label (every opencode-driven seat has its OWN label, so
197
+ // glm is `glm:<project>`, openrouter `openrouter:<project>`, a brought provider `<name>:<project>`).
198
198
  assignee: r.executor === "scrooge" || r.executor === "orchestrator" ? undefined : `${seat?.session || r.executor}:<project>`,
199
199
  // launch = the EXACT `trantor up` spec to spawn this seat; the orchestrator runs
200
200
  // `trantor up <launch> --task <task> --difficulty <difficulty>`. Carrying it explicitly is
201
- // what teaches the orchestrator the GLM path (`opencode:zai-coding-plan`) instead of guessing.
201
+ // what teaches the orchestrator the GLM path (`glm:zai-coding-plan`) instead of guessing.
202
202
  launch: ["scrooge", "orchestrator"].includes(r.executor) ? undefined : (seat?.launch || r.executor),
203
203
  // "auto" = resolve a LIVE model at spawn (the launch spec already pins the provider; the
204
204
  // runner picks the best live model for it). Was `<cli>-default` — a stale default.
package/bin/cli.mjs CHANGED
@@ -53,7 +53,7 @@ switch (cmd) {
53
53
  trantor profile declare your plans: trantor profile set claude=max codex=plus deepseek=api
54
54
  trantor provider bring ANY model (BYOM): list seats · add <name> --key … · remove <name>
55
55
  trantor models browse live models behind each seat + the router's pick per difficulty
56
- trantor up … spawn a crew here: trantor up codex kimi deepseek:deepseek opencode:zai-coding-plan
56
+ trantor up … spawn a crew here: trantor up codex kimi deepseek:deepseek glm:zai-coding-plan
57
57
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
58
58
  trantor ui open the live dashboard (board + flow views)
59
59
  trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
package/bin/crew.sh CHANGED
@@ -52,8 +52,9 @@ down() {
52
52
  case "$CMD" in up|swap) ;; *) echo "usage: crew.sh up <agent...> | crew.sh swap <oldAgent> <newAgent[:provider[/model]]> | crew.sh down"; exit 1 ;; esac
53
53
 
54
54
  # --task/--difficulty drive LAZY live-model selection for provider-only specs (agent:provider).
55
- # An agent spec is one of: `codex` (CLI default) · `opencode:zai-coding-plan` (provider only →
56
- # pick the best live model now) · `opencode:zai-coding-plan/glm-5.2` (full pin, used as-is).
55
+ # An agent spec is one of: `codex` (CLI default) · `glm:zai-coding-plan` (provider only → pick the
56
+ # best live model now) · `glm:zai-coding-plan/glm-5.2` (full pin). The agent label is the bus
57
+ # identity; any non-native label runs via opencode (legacy `opencode:zai-coding-plan` still works).
57
58
  TASK="code"; DIFF="medium"; _ARGS=()
58
59
  while [ $# -gt 0 ]; do
59
60
  case "$1" in
package/bin/doctor.mjs CHANGED
@@ -60,7 +60,7 @@ const CLIS = [
60
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." },
61
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)" },
62
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` },
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 glm:zai-coding-plan/glm-5.1` },
64
64
  // OpenRouter — the BYOM on-ramp: ONE key fronts hundreds of models. Rides opencode; the same
65
65
  // OPENROUTER_API_KEY Scrooge already uses authenticates the crew seat (the runner sources the
66
66
  // .env files). Available the moment the key exists in env/opencode + declared `openrouter=api`.
package/bin/provider.mjs CHANGED
@@ -4,11 +4,14 @@
4
4
  //
5
5
  // trantor provider # list seats: built-in + discovered, with availability + tier
6
6
  // trantor provider add <name> [--key sk-…] [--plan api|coding-plan|max] [--label <bus-name>]
7
+ // [--base-url <url> [--models m1,m2]] # wire a CUSTOM OpenAI-compatible endpoint
7
8
  // trantor provider remove <name> # drop it from your profile (leaves the key in place)
8
9
  //
9
10
  // `add` writes <NAME>_API_KEY to ~/.agent-bus/.env (if --key given), declares the plan in your
10
- // quota profile, verifies opencode can see the provider's models, and prints the seat spec. The
11
- // Advisor then routes to it automatically; run `scrooge-capabilities` so it routes well by difficulty.
11
+ // quota profile, verifies opencode can see the provider's models, and prints the seat spec. For a
12
+ // provider opencode already knows (openrouter, groq, …) the key is enough; for a CUSTOM endpoint,
13
+ // pass --base-url and it writes the opencode.json provider block for you. The Advisor then routes
14
+ // to it automatically; run `scrooge-capabilities` so it routes well by difficulty.
12
15
  import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, appendFileSync } from "node:fs";
13
16
  import { join, dirname } from "node:path";
14
17
  import { homedir } from "node:os";
@@ -23,6 +26,26 @@ const has = (c) => { try { execSync(`command -v ${c}`, { stdio: "ignore", shell:
23
26
  const C = { dim: "\x1b[2m", grn: "\x1b[32m", red: "\x1b[31m", yel: "\x1b[33m", gold: "\x1b[38;5;208m", off: "\x1b[0m" };
24
27
  const envKeyName = (p) => `${String(p).toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
25
28
 
29
+ const OC_CONFIG = join(H, ".config", "opencode", "opencode.json");
30
+ // Wire a CUSTOM OpenAI-compatible provider into opencode.json (matching opencode's schema +
31
+ // the existing providers' `options.apiKey` style). Merges, never clobbers other providers.
32
+ // `configPath` is injectable so it can be unit-tested against a temp file.
33
+ export function wireOpencodeProvider(name, baseUrl, models, configPath = OC_CONFIG) {
34
+ const cfg = existsSync(configPath) ? read(configPath, {}) : {};
35
+ cfg.$schema ||= "https://opencode.ai/config.json";
36
+ cfg.provider ||= {};
37
+ const block = {
38
+ npm: "@ai-sdk/openai-compatible",
39
+ name: name.charAt(0).toUpperCase() + name.slice(1),
40
+ options: { baseURL: baseUrl, apiKey: `{env:${envKeyName(name)}}` },
41
+ };
42
+ if (models && models.length) block.models = Object.fromEntries(models.map(m => [m, {}]));
43
+ cfg.provider[name] = { ...cfg.provider[name], ...block };
44
+ mkdirSync(dirname(configPath), { recursive: true });
45
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
46
+ return configPath;
47
+ }
48
+
26
49
  function opencodeModelCount(providerOc) {
27
50
  if (!has("opencode")) return null;
28
51
  try { return execSync(`opencode models ${providerOc} 2>/dev/null`, { encoding: "utf8" }).split("\n").filter(Boolean).length; }
@@ -47,7 +70,7 @@ function listSeats() {
47
70
  }
48
71
 
49
72
  function addProvider(name, opts) {
50
- if (!name) { console.error("usage: trantor provider add <name> [--key sk-…] [--plan api] [--label <bus-name>]"); process.exit(1); }
73
+ if (!name) { console.error("usage: trantor provider add <name> [--key sk-…] [--plan api] [--label <bus-name>] [--base-url <url> [--models m1,m2]]"); process.exit(1); }
51
74
  const provider = name.toLowerCase();
52
75
  const label = (opts.label || provider).toLowerCase().replace(/[^a-z0-9-]/g, "-");
53
76
  const plan = (opts.plan || "api").toLowerCase();
@@ -73,13 +96,20 @@ function addProvider(name, opts) {
73
96
  console.log(`${C.grn}✓${C.off} profile: ${provider}=${plan}`);
74
97
  } catch (e) { console.log(`${C.yel}⚠${C.off} could not set profile (run: trantor profile set ${provider}=${plan})`); }
75
98
 
76
- // 3) verify opencode can see the provider's models
99
+ // 3) custom endpoint → write the opencode.json provider block (known providers skip this)
100
+ if (opts.baseUrl) {
101
+ const models = (opts.models || "").split(",").map(s => s.trim()).filter(Boolean);
102
+ const where = wireOpencodeProvider(provider, opts.baseUrl, models);
103
+ console.log(`${C.grn}✓${C.off} wired custom provider '${provider}' → ${where.replace(H, "~")} (baseURL ${opts.baseUrl}${models.length ? `, ${models.length} models` : ""})`);
104
+ }
105
+
106
+ // 4) verify opencode can see the provider's models
77
107
  const n = opencodeModelCount(provider);
78
108
  if (n == null) console.log(`${C.yel}⚠${C.off} opencode not on PATH — install it to run this seat (it's the universal adapter)`);
79
- else if (n === 0) console.log(`${C.yel}⚠${C.off} opencode lists 0 models for '${provider}'. If it's a known provider, the key above is enough; for a custom endpoint, add it to ~/.config/opencode/opencode.json (provider config). Then re-check: trantor models ${provider}`);
109
+ else if (n === 0) console.log(`${C.yel}⚠${C.off} opencode lists 0 models for '${provider}'.${opts.baseUrl ? " Check the baseURL/key, or pass --models m1,m2 to declare them." : " If it's a known provider, the key above is enough; for a CUSTOM endpoint re-run with --base-url <url> [--models m1,m2]."} Re-check: trantor models ${provider}`);
80
110
  else console.log(`${C.grn}✓${C.off} opencode sees ${n} models for '${provider}'`);
81
111
 
82
- // 4) score it for difficulty-aware routing + show the seat
112
+ // 5) score it for difficulty-aware routing + show the seat
83
113
  console.log(`\n${C.gold}Seat ready.${C.off} Launch it:`);
84
114
  console.log(` trantor up ${label === provider ? label : `${label}:${provider}`} ${C.dim}# live-selects the best model for the work${C.off}`);
85
115
  console.log(` trantor up ${label}:${provider}/<model> ${C.dim}# pin a specific model${C.off}`);
@@ -106,6 +136,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
106
136
  if (rest[i] === "--key") opts.key = rest[++i];
107
137
  else if (rest[i] === "--plan") opts.plan = rest[++i];
108
138
  else if (rest[i] === "--label") opts.label = rest[++i];
139
+ else if (rest[i] === "--base-url" || rest[i] === "--baseurl") opts.baseUrl = rest[++i];
140
+ else if (rest[i] === "--models") opts.models = rest[++i];
109
141
  else pos.push(rest[i]);
110
142
  }
111
143
  if (!sub || sub === "list") listSeats();
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // trantor Notification hook — surface CC's OWN background/child agents (the `claude agents` / fork / subtask
3
+ // sessions that DON'T go through the Task-tool sub-agent path, so SubagentStart/Stop never see them) on the
4
+ // board. CC fires a Notification with notification_type "agent_needs_input" (the agent paused, waiting on the
5
+ // user) or "agent_completed" (it finished). We card these as source:"cc-bg-agent" — a DISTINCT population
6
+ // from cc-subagent, so they never double-count against sub-agent notional cost.
7
+ //
8
+ // • agent_needs_input → post/flip a card to "blocked" (needs input) so the board shows it's waiting.
9
+ // • agent_completed → flip/close the card to "done".
10
+ //
11
+ // UNVERIFIED-LIVE: the Notification stdin payload's exact field names for the agent id are not documented
12
+ // (session_id + notification_type + message are the confirmed fields). We read several candidates and let the
13
+ // hub key/guard on whatever we send; TRANTOR_DEBUG_NOTIFY=1 dumps the raw payload to stderr so the first real
14
+ // firing reveals the true shape. Fail-silent: a notification must never block the session.
15
+ import { readFileSync, existsSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { resolveProject, hostId } from "../lib/project.mjs";
19
+
20
+ function readStdin() {
21
+ return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
22
+ process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
23
+ setTimeout(() => res(d), 100); });
24
+ }
25
+ function relayUrl() {
26
+ if (process.env.RELAY_URL) return process.env.RELAY_URL;
27
+ try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
28
+ return "http://127.0.0.1:4477";
29
+ }
30
+
31
+ try {
32
+ const input = JSON.parse((await readStdin()) || "{}");
33
+ if (process.env.TRANTOR_DEBUG_NOTIFY === "1") process.stderr.write(`[trantor] notify payload: ${JSON.stringify(input)}\n`);
34
+ const ntype = String(input.notification_type || input.notificationType || input.type || "");
35
+ // only the two agent-lifecycle notification types are board-relevant; ignore permission/idle/auth/elicitation
36
+ if (ntype !== "agent_needs_input" && ntype !== "agent_completed") { process.stdout.write("{}"); process.exit(0); }
37
+
38
+ const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
39
+ const project = resolveProject(cwd);
40
+ // candidate id for pairing needs_input→completed and for guarding vs an already-carded cc-subagent. The
41
+ // agent's OWN session id is the most likely stable key for a background/child agent across both events.
42
+ const agentId = String(input.agent_id || input.agentId || input.session_id || input.sessionId || "").slice(0, 120);
43
+ const agentType = String(input.agent_type || input.agentType || "agent").slice(0, 40);
44
+ // parent = the session that spawned it → nest under that session's focus card (same rule as cc-subagent)
45
+ const parent = String(input.parent_session_id || input.parentSessionId || "").slice(0, 120);
46
+ // a lightly-cleaned human label; Notification carries a `message`. NOTE: Teams privacy gate applies before
47
+ // this reaches a shared board (it can carry prompt text) — same rule as focus-card titles.
48
+ const msg = String(input.message || "").replace(/\s+/g, " ").trim().slice(0, 90);
49
+ const title = `${agentType}${msg ? `: ${msg}` : ""}`.slice(0, 180);
50
+
51
+ await fetch(`${relayUrl()}/task`, {
52
+ method: "POST", headers: { "content-type": "application/json" },
53
+ body: JSON.stringify({
54
+ project, source: "cc-bg-agent", notificationType: ntype,
55
+ title, agentId, agentType, parent,
56
+ assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`, phase: "sub-agents",
57
+ }),
58
+ signal: AbortSignal.timeout(1500),
59
+ }).catch(() => {});
60
+ } catch (e) {
61
+ process.stderr.write(`[trantor] agent-notify error: ${e?.message || e}\n`);
62
+ }
63
+ process.stdout.write("{}");
64
+ process.exit(0);
package/hooks/hooks.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "trantor — auto-register each session + inject live roster (SessionStart); turn each substantive user prompt into the session's live 'focus' card so a regular session's own work shows IN PROGRESS (UserPromptSubmit); post an in-flight 'doing' card when a sub-agent is dispatched (PreToolUse); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); card each sub-agent's notional API cost when it finishes (SubagentStop)",
2
+ "description": "trantor — auto-register each session + inject live roster (SessionStart); turn each substantive user prompt into the session's live 'focus' card so a regular session's own work shows IN PROGRESS (UserPromptSubmit); post an in-flight 'doing' card when a sub-agent is dispatched (PreToolUse) and enrich it with the native agent_id + parent session when the sub-agent spawns (SubagentStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); surface CC's own background/child agents (fork/--agent/subtask) on the board when they need input or complete (Notification); card each sub-agent's notional API cost when it finishes (SubagentStop)",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs" } ] }
@@ -10,6 +10,9 @@
10
10
  "PreToolUse": [
11
11
  { "matcher": "Task|Agent", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/subagent-start.mjs" } ] }
12
12
  ],
13
+ "SubagentStart": [
14
+ { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/subagent-start.mjs" } ] }
15
+ ],
13
16
  "PostToolUse": [
14
17
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] },
15
18
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/inbox-deliver.mjs" } ] },
@@ -18,6 +21,9 @@
18
21
  "PreCompact": [
19
22
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/precompact.mjs" } ] }
20
23
  ],
24
+ "Notification": [
25
+ { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/agent-notify.mjs" } ] }
26
+ ],
21
27
  "SubagentStop": [
22
28
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/subagent-cost.mjs" } ] }
23
29
  ]
@@ -81,6 +81,10 @@ try {
81
81
  const project = resolveProject(cwd);
82
82
  const agentType = String(input.agent_type || "subagent").slice(0, 40);
83
83
  const effort = input.effort?.level || "";
84
+ // agent_id pairs this completion with its SubagentStart "doing" card (robust key, replaces the fragile
85
+ // title match); parent nests it under the spawning session's focus card. Both native SubagentStop fields.
86
+ const agentId = String(input.agent_id || "").slice(0, 80);
87
+ const parent = String(input.parent_session_id || input.session_id || "").slice(0, 120);
84
88
 
85
89
  const file = findTranscript(input);
86
90
  if (!file) { process.stderr.write("[trantor] subagent-cost: no transcript found\n"); process.stdout.write("{}"); process.exit(0); }
@@ -104,7 +108,7 @@ try {
104
108
  await fetch(`${relayUrl()}/task`, {
105
109
  method: "POST", headers: { "content-type": "application/json" },
106
110
  body: JSON.stringify({
107
- project, title, status: "done",
111
+ project, title, status: "done", agentType, agentId, parent,
108
112
  assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`,
109
113
  source: "cc-subagent", costKind: "subagent-notional",
110
114
  costUsd: safeUsd, costNote: safeNote, model, effort, tokens: suspect ? null : tokens,
@@ -1,9 +1,22 @@
1
1
  #!/usr/bin/env node
2
- // trantor PreToolUse hook (Task|Agent) — when a sub-agent is DISPATCHED, post an in-flight "doing" card so
3
- // the board shows work IN PROGRESS while it runs. The existing SubagentStop hook (subagent-cost.mjs) flips
4
- // the matching card to "done" via the hub's cc-subagent title-fingerprint dedup. Without this, every auto-
5
- // card was born "done" (SubagentStop/git-backfill) so nothing ever showed as in progress. Fail-silent:
6
- // never block or delay the dispatch.
2
+ // trantor sub-agent in-flight hook — posts/enriches a "doing" card so the board shows sub-agent work IN
3
+ // PROGRESS while it runs. The SubagentStop hook (subagent-cost.mjs) flips it to "done" (with notional cost).
4
+ //
5
+ // This ONE script serves TWO hook events (dispatch on input.hook_event_name):
6
+ //
7
+ // • PreToolUse (matcher Task|Agent) — fires at DISPATCH TIME and carries tool_input.prompt, so it has a
8
+ // good human title. It CREATES the in-flight card (source:"cc-subagent", title-fingerprinted). Works on
9
+ // every CC version → the universal in-flight source, no regression on older CC.
10
+ //
11
+ // • SubagentStart (native, CC 2.1.x+) — fires when the sub-agent actually SPAWNS and carries the real
12
+ // agent_id + agent_type (+ parent session), but NO prompt. It ENRICHES the card the PreToolUse create
13
+ // already made: stamps agent_id (robust start↔stop pairing key, replacing the fragile title match) and
14
+ // parent (for nesting sub-agents under the session focus card). If no create card exists (a spawn with
15
+ // no matching PreToolUse — rare), it CREATES one keyed by agent_id so nothing orphans.
16
+ //
17
+ // Registering BOTH never double-posts: on modern CC the PreToolUse create runs first, then SubagentStart
18
+ // finds that card by (project, agentType) and enriches in place (creates nothing). Fail-silent throughout —
19
+ // never block or delay a dispatch.
7
20
  import { readFileSync, existsSync } from "node:fs";
8
21
  import { join } from "node:path";
9
22
  import { homedir } from "node:os";
@@ -19,26 +32,45 @@ function relayUrl() {
19
32
  try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
20
33
  return "http://127.0.0.1:4477";
21
34
  }
35
+ const post = (url, body) => fetch(url, {
36
+ method: "POST", headers: { "content-type": "application/json" },
37
+ body: JSON.stringify(body), signal: AbortSignal.timeout(1500),
38
+ }).catch(() => {});
22
39
 
23
40
  try {
24
41
  const input = JSON.parse((await readStdin()) || "{}");
25
- const ti = input.tool_input || {};
26
42
  const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
27
43
  const project = resolveProject(cwd);
28
- const agentType = String(ti.subagent_type || "subagent").slice(0, 40);
29
- // MUST mirror subagent-cost.mjs's title derivation (lines 100-101) so the hub's title fingerprint pairs
30
- // this start card with the SubagentStop "done" card into ONE rolling cc-subagent card.
31
- const task = String(ti.prompt || ti.description || agentType).replace(/\s+/g, " ").trim().slice(0, 90);
32
- const title = `${agentType}: ${task}`.slice(0, 180);
33
- await fetch(`${relayUrl()}/task`, {
34
- method: "POST", headers: { "content-type": "application/json" },
35
- body: JSON.stringify({
36
- project, title, status: "doing",
44
+ const url = `${relayUrl()}/task`;
45
+
46
+ if (input.hook_event_name === "SubagentStart") {
47
+ // ENRICH path — the sub-agent has spawned; attach its real id + parent to the in-flight card.
48
+ // agent_type is the native SubagentStart field (PreToolUse used tool_input.subagent_type).
49
+ const agentType = String(input.agent_type || input.subagent_type || "subagent").slice(0, 40);
50
+ const agentId = String(input.agent_id || "").slice(0, 80);
51
+ // parent = the session that spawned this sub-agent → nest it under that session's focus card.
52
+ const parent = String(input.parent_session_id || input.session_id || "").slice(0, 120);
53
+ if (agentId) {
54
+ await post(url, {
55
+ project, enrich: true, agentType, agentId, parent,
56
+ by: `${hostId()}:${project}`,
57
+ source: "cc-subagent", costKind: "subagent-notional", phase: "sub-agents",
58
+ });
59
+ }
60
+ } else {
61
+ // CREATE path (PreToolUse Task|Agent) — dispatch time, has the prompt → good title.
62
+ const ti = input.tool_input || {};
63
+ const agentType = String(ti.subagent_type || "subagent").slice(0, 40);
64
+ // MUST mirror subagent-cost.mjs's title derivation so the hub's title fingerprint pairs this start card
65
+ // with the SubagentStop "done" card on any client that predates agent_id pairing (legacy fallback).
66
+ const task = String(ti.prompt || ti.description || agentType).replace(/\s+/g, " ").trim().slice(0, 90);
67
+ const title = `${agentType}: ${task}`.slice(0, 180);
68
+ await post(url, {
69
+ project, title, status: "doing", agentType,
37
70
  assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`,
38
71
  source: "cc-subagent", costKind: "subagent-notional", phase: "sub-agents",
39
- }),
40
- signal: AbortSignal.timeout(1500),
41
- }).catch(() => {});
72
+ });
73
+ }
42
74
  } catch (e) {
43
75
  process.stderr.write(`[trantor] subagent-start error: ${e?.message || e}\n`);
44
76
  }
package/hub.mjs CHANGED
Binary file
package/mcp.mjs CHANGED
@@ -189,10 +189,12 @@ server.tool("relay_inbox", "Read NEW messages addressed to this session since th
189
189
  return { content: [{ type: "text", text: messages.length ? messages.map(fmt).join("\n") : "(no new messages)" }] };
190
190
  });
191
191
 
192
- server.tool("relay_wait", "Block up to `timeout` seconds waiting for the next message to this session (long-poll). Returns the instant a message arrives. When idle, park by calling this repeatedly. IMPORTANT: some MCP clients cap tool calls (Codex ~120s, OpenCode ~60s) — use timeout 50 and loop, unless you know your client allows more (Claude Code handles 280).",
193
- { timeout: z.number().optional().describe("seconds to wait, default 25, max 280. Use 50 and call repeatedly for cross-client safety; only Claude Code reliably supports 280.") },
192
+ server.tool("relay_wait", "Block up to `timeout` seconds waiting for the next message to this session (long-poll). Returns the instant a message arrives. When idle, park by calling this repeatedly. IMPORTANT: MCP clients cap or background long tool calls — OpenCode ~60s; Codex ~120s; Claude Code auto-backgrounds ANY MCP call at 120s (CC 2.1.212+, tunable via CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS), which breaks inline parking. Use timeout 50 and loop for cross-client safety.",
193
+ { timeout: z.number().optional().describe("seconds to wait, default 25. Effective wait is capped at 110s so the call always returns inline (a longer wait would be auto-backgrounded by Claude Code's 120s MCP floor or dropped by other clients). Use 50 and call repeatedly to park while idle.") },
194
194
  async ({ timeout }) => {
195
- const w = Math.min(timeout ?? 25, 280);
195
+ // Cap below the 120s MCP auto-background floor (CC 2.1.212+) so relay_wait always
196
+ // resolves inline on every current client instead of being shipped to the background.
197
+ const w = Math.min(timeout ?? 25, 110);
196
198
  const { messages, cursor: c } = await api("GET", `/poll?session=${encodeURIComponent(SESSION)}&since=${cursor}&wait=${w}`);
197
199
  cursor = c;
198
200
  return { content: [{ type: "text", text: messages.length ? messages.map(fmt).join("\n") : "(timed out, no message)" }] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.47",
3
+ "version": "0.17.49",
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 && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-focus.mjs && python3 engine/test-routing.py"
13
+ "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && python3 engine/test-routing.py"
14
14
  },
15
15
  "description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
16
16
  "files": [
@@ -49,11 +49,11 @@ EXACT launch spec per provider (do not improvise these):
49
49
  | Codex | `codex` (or `codex:gpt-5.5` to pin) | OpenAI CLI |
50
50
  | Kimi | `kimi` | Moonshot coding-plan |
51
51
  | DeepSeek | `deepseek:deepseek` | runs via opencode; `deepseek` alone = CLI default |
52
- | **GLM (Z.ai)** | **`opencode:zai-coding-plan`** | **runs via opencode, NOT a bare `glm`/`zai` terminal command.** `opencode:zai-coding-plan/glm-5.2` pins a model; `opencode:zai-coding-plan` live-selects. |
52
+ | **GLM (Z.ai)** | **`glm:zai-coding-plan`** | **runs via opencode, NOT a bare `glm`/`zai` terminal command.** `glm:zai-coding-plan/glm-5.2` pins a model; `glm:zai-coding-plan` live-selects. (Legacy `opencode:zai-coding-plan` still works.) |
53
53
  | **OpenRouter (BYOM)** | **`openrouter`** | **the bring-your-own-model on-ramp — one key fronts hundreds of vendors (incl. ones with no CLI).** Bare `openrouter` live-selects the best OpenRouter model for the difficulty; pin one with `openrouter:openrouter/<vendor>/<model>`. Its own bus identity `openrouter:<project>` (never collides with the GLM `opencode` seat). |
54
54
 
55
55
  `agent:provider` live-selects the best model now; `agent:provider/model` pins one. Example:
56
- `trantor up codex kimi deepseek:deepseek opencode:zai-coding-plan --task code --difficulty hard`.
56
+ `trantor up codex kimi deepseek:deepseek glm:zai-coding-plan --task code --difficulty hard`.
57
57
  **Whatever the advisor's `launch` field says, run that verbatim** — the roster above is just the
58
58
  built-in menu, but the advisor picks the right seats/specs for THIS work (a user who's only brought
59
59
  an OpenRouter key gets `openrouter` for everything; one who brought five providers gets a
@@ -66,7 +66,7 @@ advisor routes to a brought provider you don't recognize, that's expected — ru
66
66
 
67
67
  ⚠️ **Gemini CLI is RETIRED (Google killed the free seat 2026-06-18).** The advisor no longer
68
68
  offers it and you must NOT fire up `gemini` — `gemini --yolo` exits 1 and crash-loops on the
69
- bus. Its replacement seat is **GLM via `opencode:zai-coding-plan`**. (Gemini still serves as a
69
+ bus. Its replacement seat is **GLM via `glm:zai-coding-plan`**. (Gemini still serves as a
70
70
  Scrooge cheap-model via `GEMINI_API_KEY` — that's a separate, working path, not a crew seat.)
71
71
  Only a holder of a paid Gemini enterprise key should ever `trantor up gemini`.
72
72