trantor 0.17.46 → 0.17.48
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 +2 -2
- package/bin/advise.mjs +65 -40
- package/bin/cli.mjs +5 -1
- package/bin/crew-runner.mjs +8 -2
- package/bin/crew.sh +13 -12
- package/bin/doctor.mjs +1 -1
- package/bin/models.mjs +67 -0
- package/bin/provider.mjs +147 -0
- package/package.json +1 -1
- package/skills/crew/SKILL.md +12 -8
|
@@ -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.48"
|
|
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.
|
|
16
|
+
"version": "0.17.48",
|
|
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.48",
|
|
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
|
@@ -134,7 +134,7 @@ project takes over with a full window (and a PreCompact hook does this automatic
|
|
|
134
134
|
(`solo | scrooge | crew | hybrid`), a routing table with a **reason per package**, why
|
|
135
135
|
that many seats ("seats follow the work, not the install list"), and a real-money estimate
|
|
136
136
|
with quota-pool accounting. You say go.
|
|
137
|
-
2. **Windows open.** `trantor up codex kimi deepseek:deepseek
|
|
137
|
+
2. **Windows open.** `trantor up codex kimi deepseek:deepseek glm:zai-coding-plan` spawns one titled
|
|
138
138
|
terminal window per agent. `agent:model` pins a model; `agent:provider --difficulty hard`
|
|
139
139
|
picks the **best live model** for the work at spawn (capability × cost), enumerated from the
|
|
140
140
|
CLI itself — never a guessed endpoint. **Serialized and then verified on the bus** — the
|
|
@@ -236,7 +236,7 @@ trantor setup | doctor | connect | profile | up <agents…> | swap <old> <new> |
|
|
|
236
236
|
|
|
237
237
|
`trantor up` notes: `agent:model` pins a model (`deepseek:deepseek-v4-pro`); `agent:provider
|
|
238
238
|
--task <k> --difficulty <d>` picks the **best live model** for the work at spawn
|
|
239
|
-
(`
|
|
239
|
+
(`glm:zai-coding-plan --difficulty hard`); spawns are verified on the bus with one retry;
|
|
240
240
|
geometry auto-detects the screen you're working on (`CREW_RECT="X,Y,W,H"` to override). `trantor
|
|
241
241
|
swap <oldAgent> <newSpec>` replaces an exhausted agent with a live-selected one. `trantor down`
|
|
242
242
|
kills crew processes via their ttys and closes windows without macOS "Terminate?" dialogs.
|
package/bin/advise.mjs
CHANGED
|
@@ -19,52 +19,73 @@ import { pathToFileURL } from "node:url";
|
|
|
19
19
|
const H = homedir();
|
|
20
20
|
const read = (p, fb) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return fb; } };
|
|
21
21
|
|
|
22
|
-
// ----
|
|
23
|
-
// Each
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// ---- crew roster: BUILT-IN seats + ANY opencode provider the user has brought (BYOM) ----
|
|
23
|
+
// Each seat: the CLI binary that must exist (`cli`) · the `trantor up` LAUNCH spec · the bus
|
|
24
|
+
// SESSION label (its identity on the board) · the profile PROVIDER key (tier/cost) · for
|
|
25
|
+
// opencode-driven seats, the opencode PROVIDER id (`providerOc`) used to enumerate models + auth.
|
|
26
26
|
//
|
|
27
|
-
// GEMINI is deliberately
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// live-selects the best OpenRouter model for the work's difficulty (or pin one:
|
|
42
|
-
// `openrouter:openrouter/<vendor>/<model>`). The model picked is what determines its strength,
|
|
43
|
-
// so it sits LAST in every CREW_PREF tier — a wildcard that fills once the proven native seats
|
|
44
|
-
// are taken, and the ONLY seat for a user who brought nothing but an OpenRouter key.
|
|
45
|
-
openrouter: { cli: "opencode", launch: "openrouter:openrouter", session: "openrouter", provider: "openrouter" },
|
|
27
|
+
// GEMINI is deliberately absent: Google retired the free CLI seat (2026-06-18) → `gemini --yolo`
|
|
28
|
+
// crashes exit 1. Its replacement is GLM via opencode.
|
|
29
|
+
//
|
|
30
|
+
// The opencode-driven seats are the BYOM substrate: opencode is a UNIVERSAL adapter, so any
|
|
31
|
+
// provider the user configures in opencode (or declares in their profile) becomes a crew seat
|
|
32
|
+
// with ZERO code change here — `buildRoster()` discovers them at runtime. The built-ins below are
|
|
33
|
+
// just the curated defaults + the two opencode seats with non-obvious mappings (glm: profile key
|
|
34
|
+
// `zai` ↔ opencode provider `zai-coding-plan`).
|
|
35
|
+
export const BUILTIN_ROSTER = {
|
|
36
|
+
codex: { cli: "codex", launch: "codex", session: "codex", provider: "codex" },
|
|
37
|
+
kimi: { cli: "kimi", launch: "kimi", session: "kimi", provider: "kimi" },
|
|
38
|
+
deepseek: { cli: "opencode", launch: "deepseek:deepseek", session: "deepseek", provider: "deepseek", providerOc: "deepseek" },
|
|
39
|
+
glm: { cli: "opencode", launch: "glm:zai-coding-plan", session: "glm", provider: "zai", providerOc: "zai-coding-plan" },
|
|
40
|
+
openrouter: { cli: "opencode", launch: "openrouter:openrouter", session: "openrouter", provider: "openrouter", providerOc: "openrouter" },
|
|
46
41
|
};
|
|
42
|
+
// opencode provider ids already claimed by a built-in (so discovery never duplicates them) + the
|
|
43
|
+
// names that are native CLIs / built-in profile aliases (never opencode-driven seats).
|
|
44
|
+
const BUILTIN_OC = new Set(Object.values(BUILTIN_ROSTER).filter(s => s.providerOc).map(s => s.providerOc));
|
|
45
|
+
const NEVER_DISCOVER = new Set(["claude", "codex", "kimi", "gemini", "zai", "opencode"]);
|
|
46
|
+
|
|
47
|
+
// Discover opencode providers the user has configured — from opencode.json `provider` keys AND
|
|
48
|
+
// from profile providers declared via `trantor provider add` — that aren't already built-in. Each
|
|
49
|
+
// becomes an opencode-driven seat under its OWN bus label (distinct session, no collisions). THIS
|
|
50
|
+
// is what lets a brought provider (Inception, a Japanese model, any opencode vendor) light up a
|
|
51
|
+
// seat with no code edit. T2's capability ingestion then makes it route well by difficulty.
|
|
52
|
+
export function discoverSeats(profile, ocConfig) {
|
|
53
|
+
const out = {};
|
|
54
|
+
const provKeys = new Set([...Object.keys(ocConfig?.provider || {}), ...Object.keys(profile?.providers || {})]);
|
|
55
|
+
for (const p of provKeys) {
|
|
56
|
+
if (BUILTIN_OC.has(p) || NEVER_DISCOVER.has(p)) continue;
|
|
57
|
+
const label = String(p).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
58
|
+
out[label] = { cli: "opencode", launch: `${label}:${p}`, session: label, provider: p, providerOc: p, discovered: true };
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildRoster(profile, ocConfig) {
|
|
64
|
+
return { ...BUILTIN_ROSTER, ...discoverSeats(profile, ocConfig) };
|
|
65
|
+
}
|
|
47
66
|
|
|
48
67
|
export function loadWorld() {
|
|
49
68
|
const profile = read(join(H, ".agent-bus", "profile.json"), { providers: {} });
|
|
50
69
|
const registry = read(join(H, ".token-scrooge", "registry.json"), { models: {}, tasks: {} });
|
|
51
70
|
const caps = read(join(H, ".token-scrooge", "capabilities.json"), {});
|
|
71
|
+
const ocConfig = read(join(H, ".config", "opencode", "opencode.json"), {});
|
|
72
|
+
const roster = buildRoster(profile, ocConfig);
|
|
52
73
|
const has = (c) => { try { execSync(`command -v ${c}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
|
|
53
|
-
const opencodeKey = (prov) => !!
|
|
74
|
+
const opencodeKey = (prov) => !!ocConfig?.provider?.[prov]?.options?.apiKey;
|
|
54
75
|
// a key the user already has for Scrooge counts too — the opencode runner sources these .env
|
|
55
|
-
// files, so OPENROUTER_API_KEY in ~/.token-scrooge/.env lights up the
|
|
76
|
+
// files, so e.g. OPENROUTER_API_KEY in ~/.token-scrooge/.env lights up the seat with no extra setup.
|
|
56
77
|
const envHasKey = (k) => !!process.env[k] || [join(H, ".token-scrooge", ".env"), join(H, ".agent-bus", ".env")]
|
|
57
78
|
.some(f => { try { return readFileSync(f, "utf8").includes(k); } catch { return false; } });
|
|
58
|
-
// a seat is available only if its CLI exists AND
|
|
59
|
-
// binary with a dead/missing seat
|
|
79
|
+
// a seat is available only if its CLI exists AND (for opencode-driven seats) the provider is
|
|
80
|
+
// actually set up — a present binary with a dead/missing seat must NOT be recommended.
|
|
60
81
|
const hasSeat = (tok) => {
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
return
|
|
82
|
+
const s = roster[tok]; if (!s || !has(s.cli)) return false;
|
|
83
|
+
if (s.cli !== "opencode") return true; // native CLI present = ready
|
|
84
|
+
const envKey = `${String(s.providerOc).toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
85
|
+
return opencodeKey(s.providerOc) || envHasKey(envKey) || !!profile?.providers?.[s.provider];
|
|
65
86
|
};
|
|
66
|
-
const agents = Object.keys(
|
|
67
|
-
return { profile, registry, caps, agents, scrooge: has("scrooge") };
|
|
87
|
+
const agents = Object.keys(roster).filter(hasSeat);
|
|
88
|
+
return { profile, registry, caps, roster, agents, scrooge: has("scrooge") };
|
|
68
89
|
}
|
|
69
90
|
|
|
70
91
|
const tierOf = (profile, prov) => profile?.providers?.[prov]?.tier || "api";
|
|
@@ -86,7 +107,11 @@ const FORECAST = { easy: 0.3e6, medium: 1.5e6, hard: 6e6 }; // tokens
|
|
|
86
107
|
const CREW_PREF = { hard: ["codex", "glm", "kimi", "deepseek", "openrouter"], medium: ["kimi", "glm", "codex", "deepseek", "openrouter"], easy: ["deepseek", "kimi", "glm", "codex", "openrouter"] };
|
|
87
108
|
|
|
88
109
|
export function advise(input, world = loadWorld()) {
|
|
89
|
-
const { profile, registry, caps, agents, scrooge } = world;
|
|
110
|
+
const { profile, registry, caps, agents, scrooge, roster = BUILTIN_ROSTER } = world;
|
|
111
|
+
// brought (discovered) opencode providers extend the preference list — appended LAST in every
|
|
112
|
+
// tier (unknown strength a priori, like openrouter), so they fill once the curated seats are
|
|
113
|
+
// taken, and are the only option for a user who brought nothing but a custom provider.
|
|
114
|
+
const broughtPref = Object.keys(roster).filter(t => roster[t].discovered && !CREW_PREF.hard.includes(t));
|
|
90
115
|
const pkgs = (input.packages || []).map(p => ({ title: p.title || "work", difficulty: ["easy", "medium", "hard"].includes(p.difficulty) ? p.difficulty : "medium", kind: p.kind || "code", owner: p.owner === "self" ? "self" : (/(foundation|integration|scaffold)/i.test(p.title) ? "self" : "") }));
|
|
91
116
|
const horizon = input.horizon || (pkgs.length >= 4 ? "long" : pkgs.length >= 2 ? "medium" : "short");
|
|
92
117
|
const orchTier = tierOf(profile, "claude");
|
|
@@ -121,10 +146,10 @@ export function advise(input, world = loadWorld()) {
|
|
|
121
146
|
}
|
|
122
147
|
if (p.owner === "self") return { ...p, executor: "orchestrator", pool: tierOf(profile, "claude"), reason: "architect-owned (foundation/integration doctrine) — the orchestrator keeps the shared contract in its own hands" };
|
|
123
148
|
if (mode === "solo") return { ...p, executor: "orchestrator", pool: tierOf(profile, "claude"), reason: "small enough to do inline" };
|
|
124
|
-
const pref = CREW_PREF[p.difficulty].filter(a => agents.includes(a));
|
|
149
|
+
const pref = [...CREW_PREF[p.difficulty], ...broughtPref].filter(a => agents.includes(a));
|
|
125
150
|
const agent = pref.sort((a, b) => (used[a] || 0) - (used[b] || 0))[0] || agents[0] || "deepseek";
|
|
126
151
|
used[agent] = (used[agent] || 0) + 1;
|
|
127
|
-
const pool = tierOf(profile,
|
|
152
|
+
const pool = tierOf(profile, roster[agent]?.provider || agent);
|
|
128
153
|
let est = null;
|
|
129
154
|
if (pool === "api") { // deepseek API etc — estimate real $ via registry
|
|
130
155
|
const m = registry.models?.["deepseek-v4-flash"] || { cost_in: 0.14, cost_out: 0.28 };
|
|
@@ -165,15 +190,15 @@ export function advise(input, world = loadWorld()) {
|
|
|
165
190
|
const selfPkgs = routing.filter(r => r.executor === "orchestrator");
|
|
166
191
|
const foundationIdx = selfPkgs.length ? [1] : [];
|
|
167
192
|
const cards = routing.map((r, i) => {
|
|
168
|
-
const seat =
|
|
193
|
+
const seat = roster[r.executor];
|
|
169
194
|
return {
|
|
170
195
|
order: i + 1, title: r.title, difficulty: r.difficulty,
|
|
171
|
-
// bus identity = the
|
|
172
|
-
//
|
|
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>`).
|
|
173
198
|
assignee: r.executor === "scrooge" || r.executor === "orchestrator" ? undefined : `${seat?.session || r.executor}:<project>`,
|
|
174
199
|
// launch = the EXACT `trantor up` spec to spawn this seat; the orchestrator runs
|
|
175
200
|
// `trantor up <launch> --task <task> --difficulty <difficulty>`. Carrying it explicitly is
|
|
176
|
-
// what teaches the orchestrator the GLM path (`
|
|
201
|
+
// what teaches the orchestrator the GLM path (`glm:zai-coding-plan`) instead of guessing.
|
|
177
202
|
launch: ["scrooge", "orchestrator"].includes(r.executor) ? undefined : (seat?.launch || r.executor),
|
|
178
203
|
// "auto" = resolve a LIVE model at spawn (the launch spec already pins the provider; the
|
|
179
204
|
// runner picks the best live model for it). Was `<cli>-default` — a stale default.
|
package/bin/cli.mjs
CHANGED
|
@@ -18,6 +18,8 @@ switch (cmd) {
|
|
|
18
18
|
case "doctor": run("bin/doctor.mjs"); break;
|
|
19
19
|
case "connect": run("bin/connect.mjs"); break;
|
|
20
20
|
case "profile": run("bin/profile.mjs"); break;
|
|
21
|
+
case "provider": case "providers": run("bin/provider.mjs"); break;
|
|
22
|
+
case "models": run("bin/models.mjs"); break;
|
|
21
23
|
case "advise": run("bin/advise.mjs"); break;
|
|
22
24
|
case "verify": run("bin/crew-verify.mjs"); break;
|
|
23
25
|
case "up": process.argv.splice(2, 1); spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "up", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
@@ -49,7 +51,9 @@ switch (cmd) {
|
|
|
49
51
|
trantor doctor where do I stand? hub/plugin/CLIs/auth/keys/profile, with copy-paste fixes
|
|
50
52
|
trantor connect (re)wire every installed AI CLI to the bus
|
|
51
53
|
trantor profile declare your plans: trantor profile set claude=max codex=plus deepseek=api
|
|
52
|
-
trantor
|
|
54
|
+
trantor provider bring ANY model (BYOM): list seats · add <name> --key … · remove <name>
|
|
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 glm:zai-coding-plan
|
|
53
57
|
trantor down tear the crew down (kills processes, closes windows, no dialogs)
|
|
54
58
|
trantor ui open the live dashboard (board + flow views)
|
|
55
59
|
trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -78,8 +78,14 @@ const CLI = {
|
|
|
78
78
|
claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
|
|
79
79
|
next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`, mflag: " --model " },
|
|
80
80
|
};
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// BYOM: any agent label that isn't a known native CLI is treated as an opencode-driven provider
|
|
82
|
+
// seat (opencode is the universal adapter). This is what lets a BROUGHT provider — `trantor up
|
|
83
|
+
// <label>:<provider>` for any opencode vendor the user configured — run with no per-provider code
|
|
84
|
+
// here; its model id arrives pre-qualified (`<provider>/<model>`) as CREW_MODEL.
|
|
85
|
+
const NATIVE = new Set(["codex", "gemini", "kimi", "claude"]);
|
|
86
|
+
const cli = CLI[AGENT] || (NATIVE.has(AGENT) ? null : CLI.opencode);
|
|
87
|
+
if (!cli) { console.error(`unknown agent '${AGENT}' (native: ${[...NATIVE].join(", ")}; any other name = an opencode provider seat)`); process.exit(1); }
|
|
88
|
+
if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an opencode provider (BYOM)`);
|
|
83
89
|
|
|
84
90
|
const RULES = `Rules: you are ${SESSION} on the trantor crew. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go (doing -> testing -> done; run the tests in 'testing', use 'failed' + a report if they break). When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message.`;
|
|
85
91
|
|
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) · `
|
|
56
|
-
#
|
|
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
|
|
@@ -70,18 +71,18 @@ SCROOGE="$BUS_DIR/engine/bin/scrooge"
|
|
|
70
71
|
[ -f "$SCROOGE" ] || SCROOGE="$(command -v scrooge 2>/dev/null || echo scrooge)"
|
|
71
72
|
|
|
72
73
|
# resolve_model <agent> <provider> <task> <diff> -> echoes a runner-ready model id, or empty
|
|
73
|
-
# (→ CLI default).
|
|
74
|
-
#
|
|
74
|
+
# (→ CLI default). PROVIDER-AGNOSTIC: if opencode knows the provider (ANY of its vendors — built-in
|
|
75
|
+
# or BROUGHT), enumerate via `opencode models <provider>`; else self-enumerate via the provider's
|
|
76
|
+
# OpenAI-compatible /models. No hardcoded provider list — a newly-brought opencode provider routes
|
|
77
|
+
# with zero change here. Never guesses an endpoint.
|
|
75
78
|
resolve_model() {
|
|
76
79
|
local agent="$1" provider="$2" task="$3" diff="$4" cands="" out=""
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
out="$(python3 "$SCROOGE" route --provider "$provider" -t "$task" -d "$diff" --json 2>/dev/null)" ;;
|
|
84
|
-
esac
|
|
80
|
+
cands="$(opencode models "$provider" 2>/dev/null | tr '\n' ' ')"
|
|
81
|
+
if [ -n "$cands" ]; then
|
|
82
|
+
out="$(python3 "$SCROOGE" route --candidates "$cands" -t "$task" -d "$diff" --json 2>/dev/null)"
|
|
83
|
+
else
|
|
84
|
+
out="$(python3 "$SCROOGE" route --provider "$provider" -t "$task" -d "$diff" --json 2>/dev/null)"
|
|
85
|
+
fi
|
|
85
86
|
[ -n "$out" ] || { echo "[crew] live model selection failed for $agent:$provider — CLI default" >&2; return 0; }
|
|
86
87
|
printf '%s' "$out" | python3 -c 'import json,sys
|
|
87
88
|
try: print(json.load(sys.stdin).get("qualified") or "")
|
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
|
|
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/models.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor models — browse the live models behind your crew seats (opencode is the adapter).
|
|
3
|
+
//
|
|
4
|
+
// trantor models # every opencode-driven seat + how many live models it serves
|
|
5
|
+
// trantor models <provider> # list that provider's live models + what the router picks per
|
|
6
|
+
// # difficulty (so you can see hard→strong, easy→cheap at a glance)
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
12
|
+
import { buildRoster, loadWorld } from "./advise.mjs";
|
|
13
|
+
|
|
14
|
+
const H = homedir();
|
|
15
|
+
const C = { dim: "\x1b[2m", grn: "\x1b[32m", gold: "\x1b[38;5;208m", off: "\x1b[0m" };
|
|
16
|
+
const has = (c) => { try { execSync(`command -v ${c}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
|
|
17
|
+
const SCROOGE = (() => {
|
|
18
|
+
const bundled = join(dirname(dirname(fileURLToPath(import.meta.url))), "engine", "bin", "scrooge");
|
|
19
|
+
if (existsSync(bundled)) return bundled;
|
|
20
|
+
try { return execSync("command -v scrooge", { encoding: "utf8" }).trim(); } catch { return "scrooge"; }
|
|
21
|
+
})();
|
|
22
|
+
|
|
23
|
+
const liveModels = (providerOc) => {
|
|
24
|
+
try { return execSync(`opencode models ${providerOc} 2>/dev/null`, { encoding: "utf8" }).split("\n").filter(Boolean); }
|
|
25
|
+
catch { return []; }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function routePick(candList, diff) {
|
|
29
|
+
try {
|
|
30
|
+
const out = execSync(`python3 ${SCROOGE} route --candidates ${JSON.stringify(candList.join(" "))} -t code -d ${diff} --json 2>/dev/null`, { encoding: "utf8" });
|
|
31
|
+
return JSON.parse(out).qualified || "?";
|
|
32
|
+
} catch { return "?"; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function listAll() {
|
|
36
|
+
const { roster, agents } = loadWorld();
|
|
37
|
+
if (!has("opencode")) { console.log("opencode not on PATH — it's the adapter that serves crew models. Install it, then re-run."); return; }
|
|
38
|
+
console.log("CREW MODELS — opencode-driven seats (● available now)\n");
|
|
39
|
+
for (const [label, s] of Object.entries(roster)) {
|
|
40
|
+
if (s.cli !== "opencode") continue;
|
|
41
|
+
const n = liveModels(s.providerOc).length;
|
|
42
|
+
const dot = agents.includes(label) ? `${C.grn}●${C.off}` : `${C.dim}○${C.off}`;
|
|
43
|
+
console.log(` ${dot} ${label.padEnd(14)} ${C.dim}${s.providerOc}${C.off} ${n} live models ${C.dim}trantor models ${label}${C.off}`);
|
|
44
|
+
}
|
|
45
|
+
console.log(`\n${C.dim}detail + routing preview:${C.off} trantor models <provider>`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function detail(name) {
|
|
49
|
+
const { roster } = loadWorld();
|
|
50
|
+
const seat = roster[name] || Object.values(roster).find(s => s.providerOc === name);
|
|
51
|
+
const providerOc = seat?.providerOc || name;
|
|
52
|
+
const models = liveModels(providerOc);
|
|
53
|
+
if (!models.length) { console.log(`No live models for '${providerOc}' (opencode offline / no key / unknown provider).`); return; }
|
|
54
|
+
console.log(`${C.gold}${providerOc}${C.off} — ${models.length} live models\n`);
|
|
55
|
+
for (const m of models.slice(0, 60)) console.log(` ${m}`);
|
|
56
|
+
if (models.length > 60) console.log(` ${C.dim}… +${models.length - 60} more${C.off}`);
|
|
57
|
+
if (existsSync(SCROOGE) || has("scrooge")) {
|
|
58
|
+
console.log(`\n${C.gold}router picks (code task):${C.off}`);
|
|
59
|
+
for (const d of ["easy", "medium", "hard"]) console.log(` ${d.padEnd(7)} → ${routePick(models, d)}`);
|
|
60
|
+
console.log(`${C.dim}(run scrooge-capabilities to (re)score the catalog for accurate difficulty routing)${C.off}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
65
|
+
const arg = process.argv[2];
|
|
66
|
+
if (!arg) listAll(); else detail(arg.toLowerCase());
|
|
67
|
+
}
|
package/bin/provider.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor provider — bring ANY model to the crew (BYOM). opencode is a universal adapter, so a
|
|
3
|
+
// provider you configure there (or declare here) becomes a crew seat with no code change.
|
|
4
|
+
//
|
|
5
|
+
// trantor provider # list seats: built-in + discovered, with availability + tier
|
|
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
|
|
8
|
+
// trantor provider remove <name> # drop it from your profile (leaves the key in place)
|
|
9
|
+
//
|
|
10
|
+
// `add` writes <NAME>_API_KEY to ~/.agent-bus/.env (if --key given), declares the plan in your
|
|
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.
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, appendFileSync } from "node:fs";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
import { buildRoster, loadWorld } from "./advise.mjs";
|
|
21
|
+
|
|
22
|
+
const H = homedir();
|
|
23
|
+
const ENV = join(H, ".agent-bus", ".env");
|
|
24
|
+
const read = (p, fb) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return fb; } };
|
|
25
|
+
const has = (c) => { try { execSync(`command -v ${c}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
|
|
26
|
+
const C = { dim: "\x1b[2m", grn: "\x1b[32m", red: "\x1b[31m", yel: "\x1b[33m", gold: "\x1b[38;5;208m", off: "\x1b[0m" };
|
|
27
|
+
const envKeyName = (p) => `${String(p).toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
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
|
+
|
|
49
|
+
function opencodeModelCount(providerOc) {
|
|
50
|
+
if (!has("opencode")) return null;
|
|
51
|
+
try { return execSync(`opencode models ${providerOc} 2>/dev/null`, { encoding: "utf8" }).split("\n").filter(Boolean).length; }
|
|
52
|
+
catch { return 0; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function listSeats() {
|
|
56
|
+
const world = loadWorld();
|
|
57
|
+
const { roster, agents, profile } = world;
|
|
58
|
+
console.log("CREW SEATS — built-in + brought (BYOM). ● = available now\n");
|
|
59
|
+
for (const [label, s] of Object.entries(roster)) {
|
|
60
|
+
const live = agents.includes(label);
|
|
61
|
+
const tier = profile?.providers?.[s.provider]?.tier || (s.cli === "opencode" ? "—" : "");
|
|
62
|
+
const kind = s.discovered ? "brought " : "built-in";
|
|
63
|
+
const models = s.cli === "opencode" ? opencodeModelCount(s.providerOc) : null;
|
|
64
|
+
const mtxt = models == null ? "" : `${models} models`;
|
|
65
|
+
const dot = live ? `${C.grn}●${C.off}` : `${C.dim}○${C.off}`;
|
|
66
|
+
console.log(` ${dot} ${label.padEnd(14)} ${C.dim}${kind}${C.off} launch: ${s.launch.padEnd(26)} ${tier ? `tier=${tier}` : ""} ${C.dim}${mtxt}${C.off}`);
|
|
67
|
+
}
|
|
68
|
+
console.log(`\n${C.dim}add one:${C.off} trantor provider add <name> --key sk-… --plan api`);
|
|
69
|
+
console.log(`${C.dim}browse models:${C.off} trantor models [<provider>]`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function addProvider(name, opts) {
|
|
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); }
|
|
74
|
+
const provider = name.toLowerCase();
|
|
75
|
+
const label = (opts.label || provider).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
76
|
+
const plan = (opts.plan || "api").toLowerCase();
|
|
77
|
+
|
|
78
|
+
// 1) key → ~/.agent-bus/.env (the runner sources it; opencode reads <NAME>_API_KEY for known providers)
|
|
79
|
+
if (opts.key) {
|
|
80
|
+
mkdirSync(dirname(ENV), { recursive: true });
|
|
81
|
+
const k = envKeyName(provider);
|
|
82
|
+
let cur = existsSync(ENV) ? readFileSync(ENV, "utf8") : "";
|
|
83
|
+
if (new RegExp(`^${k}=`, "m").test(cur)) {
|
|
84
|
+
cur = cur.replace(new RegExp(`^${k}=.*$`, "m"), `${k}=${opts.key}`);
|
|
85
|
+
writeFileSync(ENV, cur);
|
|
86
|
+
} else {
|
|
87
|
+
appendFileSync(ENV, `${cur && !cur.endsWith("\n") ? "\n" : ""}# ${provider} — brought via 'trantor provider add'\n${k}=${opts.key}\n`);
|
|
88
|
+
}
|
|
89
|
+
try { chmodSync(ENV, 0o600); } catch {}
|
|
90
|
+
console.log(`${C.grn}✓${C.off} wrote ${envKeyName(provider)} → ~/.agent-bus/.env (chmod 600)`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 2) declare the plan in the quota profile (drives the Advisor's tier/cost reasoning)
|
|
94
|
+
try {
|
|
95
|
+
execSync(`node ${join(dirname(new URL(import.meta.url).pathname), "profile.mjs")} set ${provider}=${plan}`, { stdio: "ignore" });
|
|
96
|
+
console.log(`${C.grn}✓${C.off} profile: ${provider}=${plan}`);
|
|
97
|
+
} catch (e) { console.log(`${C.yel}⚠${C.off} could not set profile (run: trantor profile set ${provider}=${plan})`); }
|
|
98
|
+
|
|
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
|
|
107
|
+
const n = opencodeModelCount(provider);
|
|
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)`);
|
|
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}`);
|
|
110
|
+
else console.log(`${C.grn}✓${C.off} opencode sees ${n} models for '${provider}'`);
|
|
111
|
+
|
|
112
|
+
// 5) score it for difficulty-aware routing + show the seat
|
|
113
|
+
console.log(`\n${C.gold}Seat ready.${C.off} Launch it:`);
|
|
114
|
+
console.log(` trantor up ${label === provider ? label : `${label}:${provider}`} ${C.dim}# live-selects the best model for the work${C.off}`);
|
|
115
|
+
console.log(` trantor up ${label}:${provider}/<model> ${C.dim}# pin a specific model${C.off}`);
|
|
116
|
+
console.log(`${C.dim}For difficulty-aware routing across its catalog, score it once (weekly):${C.off} scrooge-capabilities`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function removeProvider(name) {
|
|
120
|
+
if (!name) { console.error("usage: trantor provider remove <name>"); process.exit(1); }
|
|
121
|
+
const FILE = join(H, ".agent-bus", "profile.json");
|
|
122
|
+
const prof = read(FILE, { providers: {} });
|
|
123
|
+
if (prof.providers && prof.providers[name.toLowerCase()]) {
|
|
124
|
+
delete prof.providers[name.toLowerCase()];
|
|
125
|
+
writeFileSync(FILE, JSON.stringify(prof, null, 2) + "\n");
|
|
126
|
+
console.log(`${C.grn}✓${C.off} removed '${name}' from your profile (the ${envKeyName(name)} key is left in place; delete it from ~/.agent-bus/.env if you want it gone)`);
|
|
127
|
+
} else {
|
|
128
|
+
console.log(`'${name}' is not in your profile.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
133
|
+
const [, , sub, ...rest] = process.argv;
|
|
134
|
+
const opts = {}; const pos = [];
|
|
135
|
+
for (let i = 0; i < rest.length; i++) {
|
|
136
|
+
if (rest[i] === "--key") opts.key = rest[++i];
|
|
137
|
+
else if (rest[i] === "--plan") opts.plan = rest[++i];
|
|
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];
|
|
141
|
+
else pos.push(rest[i]);
|
|
142
|
+
}
|
|
143
|
+
if (!sub || sub === "list") listSeats();
|
|
144
|
+
else if (sub === "add") addProvider(pos[0], opts);
|
|
145
|
+
else if (sub === "remove" || sub === "rm") removeProvider(pos[0]);
|
|
146
|
+
else { console.error(`unknown subcommand '${sub}' — use: list | add | remove`); process.exit(1); }
|
|
147
|
+
}
|
package/package.json
CHANGED
package/skills/crew/SKILL.md
CHANGED
|
@@ -49,20 +49,24 @@ 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)** | **`
|
|
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
|
|
57
|
-
**Whatever the advisor's `launch` field says, run that verbatim** — the roster above is the
|
|
58
|
-
but the advisor
|
|
59
|
-
an OpenRouter key
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
`trantor up codex kimi deepseek:deepseek glm:zai-coding-plan --task code --difficulty hard`.
|
|
57
|
+
**Whatever the advisor's `launch` field says, run that verbatim** — the roster above is just the
|
|
58
|
+
built-in menu, but the advisor picks the right seats/specs for THIS work (a user who's only brought
|
|
59
|
+
an OpenRouter key gets `openrouter` for everything; one who brought five providers gets a
|
|
60
|
+
load-balanced spread). **BYOM is fully general:** the roster is DERIVED, not hardcoded — ANY
|
|
61
|
+
opencode-supported provider the user configures becomes a seat with its own bus label, no code
|
|
62
|
+
change. A user adds one with `trantor provider add <name> --key … --plan api` (then
|
|
63
|
+
`scrooge-capabilities` to score it for difficulty routing); `trantor provider` lists all seats and
|
|
64
|
+
`trantor models [<provider>]` browses the live models + the router's pick per difficulty. If the
|
|
65
|
+
advisor routes to a brought provider you don't recognize, that's expected — run its `launch` spec.
|
|
62
66
|
|
|
63
67
|
⚠️ **Gemini CLI is RETIRED (Google killed the free seat 2026-06-18).** The advisor no longer
|
|
64
68
|
offers it and you must NOT fire up `gemini` — `gemini --yolo` exits 1 and crash-loops on the
|
|
65
|
-
bus. Its replacement seat is **GLM via `
|
|
69
|
+
bus. Its replacement seat is **GLM via `glm:zai-coding-plan`**. (Gemini still serves as a
|
|
66
70
|
Scrooge cheap-model via `GEMINI_API_KEY` — that's a separate, working path, not a crew seat.)
|
|
67
71
|
Only a holder of a paid Gemini enterprise key should ever `trantor up gemini`.
|
|
68
72
|
|