trantor 0.18.59 → 0.18.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.59",
3
+ "version": "0.18.61",
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
@@ -200,6 +200,14 @@ project takes over with a full window (and a PreCompact hook does this automatic
200
200
  **best live model** for the work at spawn (capability × cost), enumerated from the provider
201
201
  itself — never a guessed endpoint. **Serialized and then verified on the bus** — the launcher
202
202
  ends with "crew verified" or names the no-shows loudly. The orchestrator never gets a green lie.
203
+ **Seats build in their own git worktrees** (`~/.agent-bus/worktrees/<project>/<seat>`), and a
204
+ fresh checkout lacks gitignored files and cannot resolve relative sibling packages. Declare what a
205
+ worktree needs in `.trantor/worktree.json`:
206
+ `{"link": ["../sibling-repo"], "provision": [{"path": "ios/Config.swift", "mode": "link|stub|operator"}], "preflight": "npm test --silent"}`.
207
+ `trantor up` links the siblings beside the worktree, links or stubs the declared files (a real
208
+ credential is never copied; `operator` entries are named for you), runs the preflight once in the
209
+ first fresh worktree (5-minute cap) and prints `preflight ok` or the last 20 lines — broadcast on
210
+ the bus too, so the orchestrator sees it before writing contracts. No declaration, no preflight.
203
211
  3. **Work flows over the bus.** Contracts arrive as messages; each agent owns its own files;
204
212
  coordination happens in <280-char messages you can read on the dashboard. Crew members
205
213
  live under a **runner**: the CLI works one turn and exits, the runner long-polls the bus
package/bin/advise.mjs CHANGED
@@ -15,7 +15,9 @@ import { join } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { execSync } from "node:child_process";
17
17
  import { pathToFileURL } from "node:url";
18
- import { busDir, readConfig } from "../lib/project.mjs";
18
+ import { busDir, readConfig, resolveProject } from "../lib/project.mjs";
19
+ import { loadCatalog, lookup as catalogLookup, effortParams, UNCATALOGUED_STATUS } from "../lib/model-catalog.mjs";
20
+ import { benchedAt, loadSeatRecord } from "../lib/seat-record.mjs";
19
21
 
20
22
  const H = homedir();
21
23
  const read = (p, fb) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return fb; } };
@@ -119,7 +121,7 @@ const FORECAST = { easy: 0.3e6, medium: 1.5e6, hard: 6e6 }; // tokens
119
121
  const CREW_PREF = { hard: ["codex", "glm", "kimi", "deepseek", "openrouter"], medium: ["kimi", "glm", "codex", "deepseek", "openrouter"], easy: ["deepseek", "kimi", "glm", "codex", "openrouter"] };
120
122
 
121
123
  export function advise(input, world = loadWorld()) {
122
- const { profile, registry, caps, agents, scrooge, roster = BUILTIN_ROSTER } = world;
124
+ const { profile, registry, caps, agents, scrooge, roster = BUILTIN_ROSTER, record } = world;
123
125
  // brought (discovered) opencode providers extend the preference list — appended LAST in every
124
126
  // tier (unknown strength a priori, like openrouter), so they fill once the curated seats are
125
127
  // taken, and are the only option for a user who brought nothing but a custom provider.
@@ -148,17 +150,39 @@ export function advise(input, world = loadWorld()) {
148
150
 
149
151
  // ---- routing per package ----
150
152
  const used = {};
153
+ // #7762: seats benched by the record ride along so the recommendation says WHO was benched,
154
+ // at which difficulty, and what their producing nothing already cost (tokens the crew burned).
155
+ const seatFeedback = [];
151
156
  const routing = pkgs.map(p => {
152
157
  if ((mode === "hybrid" || mode === "scrooge") && p.difficulty === "easy" && scrooge) {
153
158
  const m = scroogeModelFor(registry, caps, p.kind, p.difficulty);
154
159
  const tok = FORECAST.easy;
155
160
  const cost = m ? +(tok * 0.6 * m.cost_in / 1e6 + tok * 0.4 * m.cost_out / 1e6).toFixed(3) : null;
156
- return { ...p, executor: "scrooge", model: m?.model, pool: "api", est_cost_usd: cost,
161
+ // #7777: scores pick WHICH model; the catalog says HOW to call it — attach the per-difficulty
162
+ // request parameters so the executor does not have to look them up again.
163
+ const catEntry = catalogLookup(m?.model);
164
+ const catParams = catEntry.found ? effortParams(m.model, p.difficulty, "openai-chat") : null;
165
+ const effort = catEntry.found
166
+ ? { found: true, difficulty: p.difficulty, api: "openai-chat", params: catParams || {} }
167
+ : { found: false, difficulty: p.difficulty, params: null, status: UNCATALOGUED_STATUS };
168
+ return { ...p, executor: "scrooge", model: m?.model, pool: "api", est_cost_usd: cost, effort,
157
169
  reason: `easy + stateless → cheapest capable model (${m?.model}); not worth a crew seat` };
158
170
  }
159
171
  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" };
160
172
  if (mode === "solo") return { ...p, executor: "orchestrator", pool: tierOf(profile, "claude"), reason: "small enough to do inline" };
161
- const pref = [...CREW_PREF[p.difficulty], ...broughtPref].filter(a => agents.includes(a));
173
+ const prefAll = [...CREW_PREF[p.difficulty], ...broughtPref].filter(a => agents.includes(a));
174
+ // #7762 feedback loop: a seat whose last 3 cards AT THIS DIFFICULTY in THIS project were
175
+ // empty/bounced is benched at that difficulty — the redone work counts against it, so it
176
+ // stops being "the cheap option". A bench never empties the pool (better a struck seat
177
+ // than no seat), and the record is per project — never global.
178
+ const struck = prefAll.map(a => [a, benchedAt(record, a, p.difficulty)]).filter(([, b]) => b);
179
+ const eligible = prefAll.filter(a => !benchedAt(record, a, p.difficulty));
180
+ const pref = eligible.length ? eligible : prefAll;
181
+ for (const [s, b] of struck) {
182
+ const wasted = b.wastedTokens ? ` ≈${(b.wastedTokens / 1e6).toFixed(1)}M tok burned` : "";
183
+ seatFeedback.push({ seat: s, difficulty: p.difficulty, streak: b.streak, cardIds: b.cardIds, wastedTokens: b.wastedTokens,
184
+ note: `${s} benched at ${p.difficulty}: last ${b.streak.length} cards ${b.streak.join("/")} (#${b.cardIds.join(" #")})${wasted}` });
185
+ }
162
186
  const agent = pref.sort((a, b) => (used[a] || 0) - (used[b] || 0))[0] || agents[0] || "deepseek";
163
187
  used[agent] = (used[agent] || 0) + 1;
164
188
  const pool = tierOf(profile, roster[agent]?.provider || agent);
@@ -176,6 +200,7 @@ export function advise(input, world = loadWorld()) {
176
200
  // has scored it (AA scores + price proxy + per-difficulty cost weighting → hard escalates to a
177
201
  // strong model, easy stays cheap). If it hasn't been run, routing falls back to cost-only.
178
202
  if (agent === "openrouter" && p.difficulty === "hard") why_r += ` — OpenRouter ranks capability×cost; run \`scrooge-capabilities\` to keep the catalog scored (or pin openrouter:openrouter/<vendor>/<model>)`;
203
+ if (struck.length && !struck.some(([s]) => s === agent)) why_r += ` — ${struck.map(([s]) => s).join("/")} benched at ${p.difficulty} here (last 3 cards empty/bounced; seat_feedback)`;
179
204
  return { ...p, executor: agent, pool, est_cost_usd: est, reason: why_r };
180
205
  });
181
206
  // crew-size rationale: seats are EMERGENT from the work, and we say so
@@ -188,11 +213,14 @@ export function advise(input, world = loadWorld()) {
188
213
 
189
214
  const apiCost = +(routing.reduce((s, r) => s + (r.est_cost_usd || 0), 0)).toFixed(2);
190
215
  const pools = [...new Set(routing.map(r => `${r.executor}:${r.pool}`))];
216
+ // One bench line per seat+difficulty, however many packages tripped it.
217
+ const feedback = [...new Map(seatFeedback.map(f => [`${f.seat}@${f.difficulty}`, f])).values()];
191
218
  const summary =
192
219
  `Recommendation: ${mode.toUpperCase()}. ${why.join("; ")}. ` +
193
220
  (mode === "crew" || mode === "hybrid"
194
221
  ? `Routing: ${routing.map(r => `${r.title}→${r.executor}${r.model ? `(${r.model})` : ""}`).join(", ")}. ` +
195
- `Estimated real-money cost ≈ $${apiCost} (everything on a subscription pool is $0 marginal — quota pooling across ${pools.length} pools).`
222
+ `Estimated real-money cost ≈ $${apiCost} (everything on a subscription pool is $0 marginal — quota pooling across ${pools.length} pools).` +
223
+ (feedback.length ? ` Seat record (this project): ${feedback.map(f => f.note).join("; ")}.` : "")
196
224
  : "");
197
225
  const table = ["| package | diff | executor (model) | pool | est $ | reason |", "|---|---|---|---|---|---|",
198
226
  ...routing.map(r => `| ${r.title} | ${r.difficulty} | ${r.executor}${r.model ? ` (${r.model})` : ""} | ${r.pool} | ${r.est_cost_usd ?? "—"} | ${r.reason} |`)].join("\n");
@@ -221,7 +249,12 @@ export function advise(input, world = loadWorld()) {
221
249
  ? routing.map((x, j) => j + 1).filter(j => j !== i + 1)
222
250
  : (r.executor !== "orchestrator" ? foundationIdx.filter(f => f !== i + 1) : []) };
223
251
  });
224
- return { mode, why, crew, routing, routing_table_md: table, card_args: cards, est_api_cost_usd: apiCost, quota_pools: pools, summary, orchestrator_tier: orchTier, agents_available: agents };
252
+ // #7777: crew-bound packages get their catalog effort attached at SPAWN, when the live model is
253
+ // known (bin/crew/models.mjs resolveSpec → CREW_EFFORT); the advisor only records that it is deferred.
254
+ const catalogMeta = (() => { const c = loadCatalog(); return { version: c.version, models: Object.keys(c.models).length, source: "configs/model-catalog.json" }; })();
255
+ const recommendation = { mode, why, crew, routing, routing_table_md: table, card_args: cards, est_api_cost_usd: apiCost, quota_pools: pools, summary, orchestrator_tier: orchTier, agents_available: agents, catalog: catalogMeta };
256
+ if (feedback.length) recommendation.seat_feedback = feedback;
257
+ return recommendation;
225
258
  }
226
259
 
227
260
  // ---- CLI ----
@@ -238,6 +271,10 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
238
271
  const stdin = readFileSync(0, "utf8").trim();
239
272
  input = stdin ? JSON.parse(stdin) : { packages: [] };
240
273
  }
241
- const out = advise(input);
274
+ const world = loadWorld();
275
+ // #7762: fold this project's seat record into the routing (fail-open — no hub, no record,
276
+ // and the advice is exactly what it was before the feedback loop existed).
277
+ try { world.record = await loadSeatRecord({ project: resolveProject(process.cwd()) }); } catch {}
278
+ const out = advise(input, world);
242
279
  console.log(JSON.stringify(out, null, 2));
243
280
  }
package/bin/cli.mjs CHANGED
@@ -99,6 +99,7 @@ switch (cmd) {
99
99
  case "state": run("bin/state.mjs"); break;
100
100
  case "seats": case "seat": run("bin/seats.mjs"); break;
101
101
  case "seat-why": case "why": run("bin/seat-why.mjs"); break;
102
+ case "seat-record": run("bin/seat-record.mjs"); break;
102
103
  case "orchestrate": run("bin/orchestrate.mjs"); break;
103
104
  case "app": run("bin/app.mjs"); break;
104
105
  case "patrol": run("bin/patrol.mjs"); break;
@@ -231,6 +232,7 @@ switch (cmd) {
231
232
  seats: which project lives in which directory — seats · seats add · seats up · seats login install
232
233
  trantor state a seat's working memory: show <seat> <card> [--json] · validate · reset --force · gc [--apply]
233
234
  trantor seat-why WHY a seat is down (err file, logs, pids): seat-why <agent> [--json] — quota, auth, crash, or just no pane
235
+ trantor seat-record the per-project seat record the advisor benches from (✓/∅/↩ per difficulty) — [--project p] [--reset <seat>] [--json]
234
236
  trantor watch live bus feed in the terminal
235
237
  trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
236
238
  trantor policy the autonomy ladder: show | set <project> <1-4> | link <a> <b> --reason "<why>"
package/bin/connect.mjs CHANGED
@@ -1,27 +1,25 @@
1
1
  #!/usr/bin/env node
2
- // trantor connect — wire every AI coding CLI on this machine to the bus, in one shot.
3
- //
4
- // node bin/connect.mjs # detect installed CLIs, patch each one's MCP config (idempotent)
5
- // node bin/connect.mjs --dry-run # show what would change, touch nothing
6
- //
7
- // Each CLI keeps its own MCP config file/format; this writes the one "relay" entry into each
8
- // (with a timestamped .bak backup the first time it changes a file). Claude Code is handled by
9
- // the plugin (claude plugin install trantor), so it's only verified here, not patched.
2
+ // trantor connect — wire every AI coding CLI on this machine to the bus, in one shot (idempotent;
3
+ // --dry-run touches nothing). Writes the one "relay" MCP entry into each CLI's own config format,
4
+ // with a timestamped .bak backup on first change. Claude Code rides the plugin: verified, not patched.
10
5
  import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from "node:fs";
11
6
  import { join, dirname } from "node:path";
12
7
  import { homedir } from "node:os";
13
8
  import { execSync } from "node:child_process";
14
9
  import { fileURLToPath } from "node:url";
10
+ import { resolveProject, resolveHubInfo } from "../lib/project.mjs";
15
11
 
16
12
  const DRY = process.argv.includes("--dry-run");
17
13
  const MCP = join(dirname(dirname(fileURLToPath(import.meta.url))), "mcp.mjs");
18
- const URL_ = process.env.RELAY_URL || "http://127.0.0.1:4477";
19
- // Graft (github.com/NanoNets/context-graph-engine): a local Tree-sitter dependency graph served
20
- // over MCP (graft_find_code / _find_all / _trace_calls / _file_api / _repo_map). Wired next to
21
- // `relay` so a seat can locate code with one call instead of grep+read-manythe graph refreshes
22
- // itself before each query (no freshness hook) and serves the nearest ancestor with a graft/ index,
23
- // so it keys off the seat's cwd project. A project with no graft/ index simply returns empty tools,
24
- // never an error. `graft build` (or `graft init`) seeds a project's index; graft/ is gitignored.
14
+ // Connect-time truth stamped into every relay entry env (#7893): the project this checkout resolves
15
+ // to and the hub THAT project resolves to. Some CLIs spawn MCP with a scrubbed env where even `git`
16
+ // is missing, so the stamp is the belt; the worktree path rule in lib/project.mjs stays primary.
17
+ // Env wins in resolveHubInfo, so these keys are REFRESHED on every connect runre-run after a pin change.
18
+ const PROJECT_AT_CONNECT = resolveProject(process.cwd());
19
+ const URL_ = resolveHubInfo(PROJECT_AT_CONNECT).url;
20
+ // Graft (github.com/NanoNets/context-graph-engine): local Tree-sitter dependency graph over MCP,
21
+ // wired next to `relay` so a seat locates code in one call; the graph refreshes itself per query
22
+ // and a project with no graft/ index simply returns empty tools. `graft build` seeds an index.
25
23
  const GRAFT = (() => { try { return execSync("command -v graft", { encoding: "utf8", shell: "/bin/sh" }).trim(); } catch { return "graft"; } })();
26
24
  const HAS_GRAFT = GRAFT !== "graft" || (() => { try { execSync("command -v graft", { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } })();
27
25
  const has = (cmd) => { try { execSync(`command -v ${cmd}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
@@ -43,11 +41,11 @@ function patchJson(path, mutate) {
43
41
  return exists ? "wired" : "wired (new config)";
44
42
  }
45
43
 
46
- // NO RELAY_URL here. A hardcoded URL in a CLI's MCP config OVERRIDES the per-project hub pin
47
- // (env wins in resolveHub), which silently sent every crew seat's relay tools to the local hub
48
- // while its runner sat on the pinned one the residual split-brain mechanism (2026-08-20).
49
- // mcp.mjs resolves the hub from the session's project pin; that resolution must stay in charge.
50
- const relayEnv = (agent) => ({ RELAY_AGENT: agent });
44
+ // Two keys, refreshed by every connect run: agent identity and the hub. The PROJECT is never
45
+ // stamped: these configs are global to the CLI, and a seat of that CLI in another project would
46
+ // inherit the wrong board (RELAY_PROJECT outranks the worktree rule in resolveProject, #7893).
47
+ // A user-added env key survives the refresh merge.
48
+ const relayEnv = (agent) => ({ RELAY_AGENT: agent, RELAY_URL: URL_ });
51
49
  // OpenCode hosts several differently-named seats. Its global MCP environment must not stamp all
52
50
  // of them "opencode": ambient runner identity wins, while this fallback names a normal interactive
53
51
  // OpenCode session that has no RELAY_AGENT/RELAY_SESSION of its own.
@@ -63,15 +61,31 @@ if (has("claude")) {
63
61
  report("claude", st);
64
62
  }
65
63
 
66
- // ---- Codex (TOML append no TOML lib needed) ----
64
+ // ---- Codex (TOML append a missing relay section, refresh its env when it exists) ----
65
+ const tomlRelayEnv = `env = { RELAY_AGENT = "codex", RELAY_URL = "${URL_}" }`;
67
66
  if (has("codex")) {
68
67
  const p = join(homedir(), ".codex", "config.toml");
69
- const cur = existsSync(p) ? readFileSync(p, "utf8") : "";
70
- if (cur.includes("[mcp_servers.relay]")) report("codex", "already wired");
71
- else {
72
- const block = `\n# trantor — auto-registers each Codex session on the bus + adds relay_* tools\n# (no RELAY_URL on purpose: the per-project hub pin decides the hub)\n[mcp_servers.relay]\ncommand = "node"\nargs = ["${MCP}"]\nenv = { RELAY_AGENT = "codex" }\n`;
68
+ let cur = existsSync(p) ? readFileSync(p, "utf8") : "";
69
+ if (!cur.includes("[mcp_servers.relay]")) {
70
+ const block = `\n# trantor — auto-registers each Codex session on the bus + adds relay_* tools\n# (env is REFRESHED by every \`trantor connect\`: agent + connect-time hub + project)\n[mcp_servers.relay]\ncommand = "node"\nargs = ["${MCP}"]\n${tomlRelayEnv}\n`;
73
71
  if (!DRY) { if (existsSync(p)) backup(p); else mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, cur + block); }
74
72
  report("codex", cur ? "wired" : "wired (new config)", p);
73
+ } else {
74
+ // Refresh the connect-written single-line `env = { ... }` inside the existing section; a
75
+ // hand-rolled multi-line env table is left untouched (user customization wins) and is named.
76
+ const start = cur.indexOf("[mcp_servers.relay]");
77
+ const next = cur.indexOf("\n[", start + 1);
78
+ const end = next === -1 ? cur.length : next + 1;
79
+ const section = cur.slice(start, end);
80
+ let refreshed = section, note = "already wired";
81
+ if (/^env\s*=\s*\{.*\}\s*$/m.test(section)) refreshed = section.replace(/^env\s*=.*$/m, tomlRelayEnv);
82
+ else if (!/^env\s*=/m.test(section)) refreshed = section.replace(/\n*$/, "\n") + tomlRelayEnv + "\n";
83
+ else note = "relay env kept (custom shape) — refresh it by hand";
84
+ if (refreshed !== section) {
85
+ if (!DRY) { backup(p); writeFileSync(p, cur.slice(0, start) + refreshed + cur.slice(end)); }
86
+ note = "relay env refreshed";
87
+ }
88
+ report("codex", note, p);
75
89
  }
76
90
  // graft alongside relay
77
91
  if (HAS_GRAFT) {
@@ -85,27 +99,45 @@ if (has("codex")) {
85
99
  }
86
100
  }
87
101
 
88
- // ---- Gemini CLI ---- (existing relay entries are never overwritten user customization wins)
102
+ // ---- Gemini CLI ---- (relay entry env is REFRESHED: ||= kept an older connect's stale env forever)
89
103
  if (has("gemini")) {
90
104
  const p = join(homedir(), ".gemini", "settings.json");
91
105
  report("gemini", patchJson(p, d => {
92
106
  d.mcpServers ||= {};
93
- d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("gemini") };
107
+ d.mcpServers.relay ||= { command: "node", args: [MCP], env: {} };
108
+ d.mcpServers.relay.env = { ...d.mcpServers.relay.env, ...relayEnv("gemini") };
94
109
  if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
95
110
  }), p);
96
111
  }
97
112
 
98
- // ---- Kimi CLI ----
99
- if (has("kimi")) {
100
- const p = join(homedir(), ".kimi", "mcp.json");
101
- report("kimi", patchJson(p, d => {
102
- d.mcpServers ||= {};
103
- d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("kimi") };
104
- if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
105
- }), p);
113
+ // ---- Kimi CLI + kimi-code ---- (same refresh: the stale entry that caused #7893 was {RELAY_AGENT: kimi} only)
114
+ // kimi-code is a separate install reading ~/.kimi-code/mcp.json — wiring only the old file left the
115
+ // running kimi seat on a hard-coded stale hub (#7938). Same shape and stamp; the dir is detected by
116
+ // config.toml presence — existsSync only, the config itself is never read.
117
+ const kimiRelay = (cli, p) => report(cli, patchJson(p, d => {
118
+ d.mcpServers ||= {};
119
+ d.mcpServers.relay ||= { command: "node", args: [MCP], env: {} };
120
+ d.mcpServers.relay.env = { ...d.mcpServers.relay.env, ...relayEnv("kimi") };
121
+ if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
122
+ }), p);
123
+ const kimiPaths = [join(homedir(), ".kimi", "mcp.json"), join(homedir(), ".kimi-code", "mcp.json")];
124
+ const kimiWritten = new Set();
125
+ if (has("kimi")) { kimiRelay("kimi", kimiPaths[0]); kimiWritten.add(kimiPaths[0]); }
126
+ if (existsSync(join(homedir(), ".kimi-code", "config.toml"))) { kimiRelay("kimi-code", kimiPaths[1]); kimiWritten.add(kimiPaths[1]); }
127
+ // A kimi-family config this run did NOT write still names a hub of its own; if it disagrees with
128
+ // the pin, say so — one CLI of the pair would keep registering on a different bus (#7938).
129
+ for (const p of kimiPaths) {
130
+ if (kimiWritten.has(p) || !existsSync(p)) continue;
131
+ try {
132
+ const u = JSON.parse(readFileSync(p, "utf8"))?.mcpServers?.relay?.env?.RELAY_URL;
133
+ if (u && u !== URL_) report("kimi", `WARN: ${p} still points at ${u} — re-run connect or refresh it by hand`);
134
+ } catch {}
106
135
  }
107
136
 
108
137
  // ---- OpenCode ----
138
+ // Deliberately NOT stamped with RELAY_URL/RELAY_PROJECT: OpenCode hosts several differently-named
139
+ // seats from one config, and a stamped project/hub would override every hosted seat's runner-provided
140
+ // env (the overlay bug the deletes below fix). Ambient runner env + RELAY_AGENT_FALLBACK stay in charge.
109
141
  if (has("opencode")) {
110
142
  const p = join(homedir(), ".config", "opencode", "opencode.json");
111
143
  report("opencode", patchJson(p, d => {
@@ -122,16 +154,10 @@ if (has("opencode")) {
122
154
  }), p);
123
155
  }
124
156
 
125
- // ---- DeepSeek Harness (dsh) ----
126
- // dsh has no single MCP config file composition is a PROFILE (~/.dsh/profiles/<name>): a package.json
127
- // naming the bundles it stacks and a cordis.patch.yml inserting plugin rows. We build a "trantor"
128
- // profile on the stock headless bundle and mount two rows:
129
- // 1. their Claude Code hooks bridge pointed at OUR hooks.json — presence, focus cards, heartbeats,
130
- // file claims run inside dsh exactly as they do inside CC (verified live 2026-08-19);
131
- // 2. their MCP client spawning our relay server — relay_* tools with the seat identity forwarded
132
- // from the ambient RELAY_* env (crew-runner sets those per seat).
133
- // The bridge's own protocol lib is declared as a dependency explicitly: the rc package forgets it
134
- // (ERR_MODULE_NOT_FOUND at boot without it — reported upstream).
157
+ // ---- DeepSeek Harness (dsh): composes from PROFILES (~/.dsh/profiles/<name>), no single MCP config.
158
+ // We build a "trantor" profile mounting their Claude Code hooks bridge at OUR hooks.json plus their
159
+ // MCP client running our relay server with ambient RELAY_* identity; the bridge's protocol lib is
160
+ // declared explicitly the rc package forgets it (ERR_MODULE_NOT_FOUND at boot).
135
161
  if (has("dsh")) {
136
162
  const ROOT = dirname(MCP);
137
163
  const prof = join(homedir(), ".dsh", "profiles", "trantor");
@@ -183,12 +209,10 @@ ${HAS_GRAFT ? ` - id: trantor-graft
183
209
  command: ${GRAFT}
184
210
  args: ['mcp']
185
211
  ` : ""}`;
186
- // "a profile exists" is not "a profile is current": connect grows rows over time (relay, then
187
- // graft, then whatever comes next), and an existence check short-circuits on a profile written
188
- // by an older connect forever the seat silently never gets the new row. So the gate is
189
- // CONTENT-based: every row id this connect would write must already be in the patch; a missing
190
- // one regenerates the patch (backed up). Presence, not diff — user edits to rows that ARE there
191
- // still win, the same rule as the gemini/kimi/opencode `||=` patches above.
212
+ // "a profile exists" is not "a profile is current": connect grows rows over time, and an existence
213
+ // check short-circuits on a profile written by an older connect forever. The gate is CONTENT-based:
214
+ // every row id this connect writes must already be in the patch, else regenerate (backed up).
215
+ // Presence, not diff user edits to rows that ARE there still win, like the JSON patches above.
192
216
  const expectedIds = [...patch.matchAll(/- id: (\S+)/g)].map(m => m[1]);
193
217
  const cur = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "";
194
218
  const missing = expectedIds.filter(id => !cur.split("\n").some(l => l.trim() === `- id: ${id}`));
@@ -201,11 +225,9 @@ ${HAS_GRAFT ? ` - id: trantor-graft
201
225
  if (!DRY) {
202
226
  mkdirSync(prof, { recursive: true });
203
227
  // The seat runs the plugin's hooks MINUS SessionStart: the crew runner already owns
204
- // registration/announcement, and per-turn roster/catchup injection is wasted spend in a
205
- // fresh one-shot session (headless has no resume every turn re-pays it). Note: an earlier
206
- // version of this comment blamed a dsh teardown crash on SessionStart; that was FALSE — the
207
- // crash was the duplicated-core install below, refuted by a clean-profile repro before we
208
- // reported upstream (deepseek-harness discussions #3515/#3516).
228
+ // registration/announcement, and per-turn roster injection is wasted spend in a one-shot
229
+ // session. (A dsh teardown crash was once blamed on SessionStart — FALSE: it was the
230
+ // duplicated-core install below, refuted by clean-profile repro; deepseek-harness #3515/#3516.)
209
231
  try {
210
232
  const full = JSON.parse(readFileSync(join(ROOT, "hooks", "hooks.json"), "utf8"));
211
233
  const subset = Object.fromEntries(Object.entries(full.hooks || {}).filter(([k]) => k !== "SessionStart"));
@@ -222,10 +244,8 @@ ${HAS_GRAFT ? ` - id: trantor-graft
222
244
  const rootPath = join(prof, "cordis.yml");
223
245
  if (!existsSync(rootPath)) writeFileSync(rootPath, "# dsh profile root — an empty entry list; the tree is composed from bundles + cordis.patch.yml.\n[]\n");
224
246
  // pnpm settings mirroring dsh's own profile template. autoInstallPeers:false is LOAD-BEARING:
225
- // an installer that pulls the bridge's peers drops a SECOND copy of dsh's core packages into
226
- // the profile, the loader mounts services from both module instances, and the first tool call
227
- // dies on ctx.tools[TOOL_RUNTIME_SCHEDULER] being undefined (observed: every turn that used
228
- // any tool crashed "reading 'prepare'"; tool-free turns worked).
247
+ // an installer that pulls the bridge's peers drops a SECOND copy of dsh's core into the
248
+ // profile, both instances mount, and the first tool call dies on ctx.tools being undefined.
229
249
  writeFileSync(join(prof, "pnpm-workspace.yaml"), "packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n");
230
250
  // the two bridge packages must be importable from the profile's node_modules — via pnpm
231
251
  // (peers OFF, hoisted) like dsh's own template; npm needs --legacy-peer-deps for the same
@@ -240,7 +260,7 @@ ${HAS_GRAFT ? ` - id: trantor-graft
240
260
  }
241
261
 
242
262
  const found = out.length;
243
- console.log(`trantor connect${DRY ? " (dry run)" : ""} — hub: ${URL_}`);
263
+ console.log(`trantor connect${DRY ? " (dry run)" : ""} — project: ${PROJECT_AT_CONNECT}, hub: ${URL_}`);
244
264
  for (const r of out) console.log(` ${r.cli.padEnd(9)} ${r.status}${r.detail ? ` (${r.detail})` : ""}`);
245
265
  if (!found) console.log(" no supported CLIs found on PATH (claude, codex, gemini, kimi, opencode, dsh)");
246
266
  console.log(DRY ? "\nRun without --dry-run to apply." : "\nDone. New sessions of each CLI auto-join the bus.");
package/bin/crew/cmux.mjs CHANGED
@@ -109,7 +109,7 @@ export function spawnCmux(ctx, specs, resolve, prune) {
109
109
  for (let index = 0; index < specs.length; index += 1) {
110
110
  const seat = resolve(specs[index]);
111
111
  if (!seat) continue;
112
- const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model));
112
+ const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model, seat.effort));
113
113
  const old = reuse ? readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmux" && row.agent === seat.agent).at(-1)?.handle || "" : "";
114
114
  let pane = "";
115
115
  if (reuse) {
@@ -149,7 +149,7 @@ function spawnAppleScript(ctx, specs, resolve) {
149
149
  for (let index = 0; index < specs.length; index += 1) {
150
150
  const seat = resolve(specs[index]);
151
151
  if (!seat) continue;
152
- const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model));
152
+ const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model, seat.effort));
153
153
  const old = reuse ? readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmux" && row.agent === seat.agent).at(-1)?.handle || "" : "";
154
154
  let pane;
155
155
  if (!tab && index === 0) {
package/bin/crew/core.mjs CHANGED
@@ -141,12 +141,15 @@ export function gridColumns(size) {
141
141
  // file that was never written and a runner still on the transcript path.
142
142
  const FORWARDED_ENV = ["TRANTOR_STATE", "TRANTOR_STATE_ASSEMBLE", "TRANTOR_STATE_HANDOFF", "TRANTOR_STATE_GATE"];
143
143
 
144
- export function runnerCommand(ctx, agent, model = "") {
144
+ export function runnerCommand(ctx, agent, model = "", effort = null) {
145
145
  const forwarded = FORWARDED_ENV
146
146
  .filter((name) => process.env[name])
147
147
  .map((name) => `${name}=${shellQuote(process.env[name])} `)
148
148
  .join("");
149
- return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
149
+ // #7777: the launcher resolved the catalog's per-difficulty effort for this seat; the runner
150
+ // reads CREW_EFFORT, applies what the CLI accepts and logs the one-line summary.
151
+ const effortEnv = effort ? ` CREW_EFFORT=${shellQuote(JSON.stringify(effort))}` : "";
152
+ return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)}${effortEnv} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
150
153
  }
151
154
 
152
155
  export function listPids(pattern) {
@@ -165,7 +165,7 @@ function replacementPane(ctx, workspace, spec, hostPane, resolve) {
165
165
  pane = `%DRYT${spec.index}`;
166
166
  } else {
167
167
  pane = splitPane(ctx, hostPane, "right", ctx.dir);
168
- runSeat(ctx, pane, seat.agent, runnerCommand(ctx, seat.agent, seat.model));
168
+ runSeat(ctx, pane, seat.agent, runnerCommand(ctx, seat.agent, seat.model, seat.effort));
169
169
  }
170
170
  if (old) {
171
171
  closePane(ctx, old);
@@ -202,7 +202,7 @@ export function spawnHerdr(ctx, specs, resolve, prune) {
202
202
  function freshPane(ctx, workspace, spec, panes, columns, resolve) {
203
203
  const seat = resolve(spec.value);
204
204
  if (!seat) return null;
205
- const command = runnerCommand(ctx, seat.agent, seat.model);
205
+ const command = runnerCommand(ctx, seat.agent, seat.model, seat.effort);
206
206
  let pane = "";
207
207
  if (spec.index === 0) {
208
208
  if (ctx.dry) {
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { call } from "./core.mjs";
4
4
  import { reapSeat } from "./state.mjs";
5
+ import { resolveEffort } from "../../lib/model-catalog.mjs";
5
6
 
6
7
  function routeModel(ctx, provider, candidates, task, difficulty) {
7
8
  const bundled = join(ctx.root, "engine/bin/scrooge");
@@ -36,16 +37,22 @@ export function resolveSpec(ctx, spec, task, difficulty, skipped) {
36
37
  if (agent === "glm") field = "zai-coding-plan";
37
38
  else if (!["codex", "kimi", "claude", "gemini", "dsh", "opencode"].includes(agent)) field = agent;
38
39
  }
39
- if (!field) return { agent, model: "" };
40
- if (field.includes("/")) return { agent, model: field };
40
+ if (!field) {
41
+ // #7777: CLI-default seat (kimi/codex/claude/dsh) the catalog finds the entry through the
42
+ // agent alias so the runner still gets a CREW_EFFORT record (or the uncatalogued line).
43
+ return { agent, model: "", effort: resolveEffort(agent, "", difficulty) };
44
+ }
45
+ if (field.includes("/")) return { agent, model: field, effort: resolveEffort(agent, field, difficulty) };
41
46
  if (["claude", "codex", "kimi", "gemini"].includes(agent)) {
42
47
  console.log(` → ${agent}: model ${field} (native pin)`);
43
- return { agent, model: field };
48
+ return { agent, model: field, effort: resolveEffort(agent, field, difficulty) };
44
49
  }
45
50
  try {
46
51
  const model = resolveModel(ctx, agent, field, task, difficulty);
52
+ // #7777: the live model is known — attach the catalog's per-difficulty effort parameters here
53
+ // so the runner applies them where the CLI accepts them (CREW_EFFORT, below).
47
54
  console.log(` → ${agent}: live model ${model} (${field} · ${task}/${difficulty})`);
48
- return { agent, model };
55
+ return { agent, model, effort: resolveEffort(agent, model, difficulty) };
49
56
  } catch (error) {
50
57
  console.error(error.message);
51
58
  console.error(`[crew] ✗ skipping seat '${agent}' — model resolution failed for ${field} (${task}/${difficulty}); remaining seats still launch`);
@@ -0,0 +1,70 @@
1
+ // #7760: `trantor up` used to hand out worktrees that could not build — every seat discovered the
2
+ // missing sibling package and the gitignored config at once. Up now provisions the first seat's
3
+ // worktree from .trantor/worktree.json, runs the declared preflight there once, and broadcasts.
4
+ import {
5
+ PREFLIGHT_CAP_MS, applyWorktreeDeclaration, ensureSeatWorktree, preflightLine, provisioningLines,
6
+ readWorktreeDeclaration, runPreflight,
7
+ } from "../../lib/seat-worktree.mjs";
8
+ import { gitRoot, hostId } from "../../lib/project.mjs";
9
+ import { loadOrCreate } from "../../lib/identity.mjs";
10
+ import { ensureEnrolled } from "../../lib/enroll.mjs";
11
+ import { sfetchJson } from "../../lib/signed-fetch.mjs";
12
+
13
+ const BROADCAST_MAX = 1500;
14
+
15
+ export function broadcastText(agent, applied, result) {
16
+ const parts = [];
17
+ if (result) parts.push(result.ok ? `${preflightLine(result)} in the ${agent} worktree` : `preflight failed in the ${agent} worktree (${result.command}): ${result.tail.join(" | ")}`);
18
+ else parts.push(`worktree declaration applied for ${agent} (no preflight declared)`);
19
+ if (applied.operator.length) parts.push(`operator steps: ${applied.operator.join("; ")}`);
20
+ if (applied.problems.length) parts.push(`problems: ${applied.problems.join("; ")}`);
21
+ const text = parts.join(" — ");
22
+ return text.length > BROADCAST_MAX ? `${text.slice(0, BROADCAST_MAX - 1)}…` : text;
23
+ }
24
+
25
+ async function broadcast(ctx, text, log) {
26
+ const session = ctx.env.RELAY_SESSION || `${hostId()}:${ctx.project}`;
27
+ try {
28
+ const identity = loadOrCreate(session, "agent");
29
+ await ensureEnrolled(ctx.hub, identity, ctx.project);
30
+ const r = await sfetchJson(`${ctx.hub}/send`, {
31
+ identity, payload: { from: session, to: "all", project: ctx.project, kind: "status", text },
32
+ signal: AbortSignal.timeout(5000),
33
+ });
34
+ if (!r.ok) log(`— preflight result NOT recorded on the bus (${ctx.hub}/send answered ${r.status}) —`);
35
+ return r.ok;
36
+ } catch (e) {
37
+ log(`— preflight result NOT recorded on the bus (${String(e?.message || e).slice(0, 80)}) —`);
38
+ return false;
39
+ }
40
+ }
41
+
42
+ // Returns { skipped } when the project declares nothing (unchanged behaviour), else what was
43
+ // applied, the preflight result, and whether the bus recorded it.
44
+ export async function preflightFirstSeat(ctx, agent, { capMs = PREFLIGHT_CAP_MS, log = console.log } = {}) {
45
+ const decl = readWorktreeDeclaration(gitRoot(ctx.dir) || ctx.dir);
46
+ if (!decl) return { skipped: "no declaration" };
47
+ const wt = ensureSeatWorktree({ sourceDir: ctx.dir, project: ctx.project, agent, home: ctx.home, env: ctx.env, log });
48
+ if (!wt.root) {
49
+ log(`— preflight skipped: no seat worktree for ${agent} (running from ${wt.dir}) —`);
50
+ return { skipped: "no worktree" };
51
+ }
52
+ const applied = applyWorktreeDeclaration(decl, { root: wt.root, seatDir: wt.dir });
53
+ log(`— ${agent} worktree ${wt.created ? "created" : "reused"} at ${wt.dir}; .trantor/worktree.json applied —`);
54
+ for (const l of provisioningLines(applied, { prefix: " " })) log(l);
55
+
56
+ let result = null;
57
+ if (!decl.preflight) log("— no preflight declared —");
58
+ else if (wt.dirty) log(`— preflight skipped: the ${agent} worktree has uncommitted work (commit or harvest it, then re-run up) —`);
59
+ else {
60
+ log(`— preflight in ${wt.dir}: ${decl.preflight} (cap ${Math.round(capMs / 60000)} min) —`);
61
+ result = runPreflight(decl.preflight, { seatDir: wt.dir, capMs, env: ctx.env });
62
+ log(result.ok ? `\x1b[32m${preflightLine(result)}\x1b[0m` : `\x1b[31m${preflightLine(result)}\x1b[0m`);
63
+ if (!result.ok) log(" ✗ fix the worktree need above (or the declaration) BEFORE writing contracts — the seats will hit the same wall.");
64
+ }
65
+
66
+ const worthPosting = result || applied.operator.length || applied.problems.length;
67
+ const text = worthPosting ? broadcastText(agent, applied, result) : "";
68
+ const posted = text ? await broadcast(ctx, text, log) : false;
69
+ return { applied, result, text, posted, seatDir: wt.dir, created: wt.created };
70
+ }
package/bin/crew/tmux.mjs CHANGED
@@ -7,7 +7,7 @@ export function spawnTmux(ctx, specs, resolve) {
7
7
  for (const spec of specs) {
8
8
  const seat = resolve(spec);
9
9
  if (!seat) continue;
10
- const command = runnerCommand(ctx, seat.agent, seat.model);
10
+ const command = runnerCommand(ctx, seat.agent, seat.model, seat.effort);
11
11
  let pane = ctx.dry ? "%DRY" : "";
12
12
  if (first) {
13
13
  run(ctx, "tmux", ["new-session", "-d", "-s", session, "-n", "crew", "-x", "260", "-y", "60"], { rendered: `tmux new-session -d -s '${session}' -n crew -x 260 -y 60` });
@@ -52,7 +52,7 @@ export function spawnTerminal(ctx, specs, resolve) {
52
52
  recordState(ctx, ctx.project, "win", seat.agent, `%DRYWIN${index}`);
53
53
  continue;
54
54
  }
55
- const command = `clear && ${runnerCommand(ctx, seat.agent, seat.model)}`;
55
+ const command = `clear && ${runnerCommand(ctx, seat.agent, seat.model, seat.effort)}`;
56
56
  const title = `${ctx.project} · ${seat.agent.toUpperCase()}`;
57
57
  const result = appleScript(`tell application "Terminal"\nset w to do script "${appleScriptString(command)}"\nset custom title of w to "${appleScriptString(title)}"\nset theWin to first window whose tabs contains w\nset bounds of theWin to {${x}, ${y}, ${x + width}, ${y + height}}\nreturn id of theWin\nend tell\n`);
58
58
  if (result.stdout) {