trantor 0.18.60 → 0.18.62

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.60",
3
+ "version": "0.18.62",
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
@@ -150,8 +150,13 @@ the brain
150
150
  Fix the `→` lines (each CLI's own sign-in happens once, in that CLI) and re-run `trantor doctor`
151
151
  until it's clean.
152
152
 
153
- Provider API keys (e.g. `DEEPSEEK_API_KEY`) live in one file: **`~/.agent-bus/.env`** the
154
- crew runners source it automatically, and it wins over anything Scrooge has.
153
+ Provider API keys (e.g. `DEEPSEEK_API_KEY`) start in one file, **`~/.agent-bus/.env`**, and on
154
+ macOS they belong in the keychain: `trantor secrets migrate` moves every key there and leaves a
155
+ `# NAME -> keychain` stub in its place, `trantor secrets list` shows which layer holds each key
156
+ (never a value), and `printf '%s' "$KEY" | trantor secrets set NAME` adds one. The crew runner
157
+ reads the store at every turn and hands the keys to the seat in its environment, so nothing is
158
+ copied to disk; a key still in `.env` keeps working as the fallback, and `trantor doctor` names it.
159
+ The store wins over `.env`, which wins over anything Scrooge has.
155
160
 
156
161
  That precedence is the point. Scrooge (the cheap-model router) keeps its own keys in
157
162
  `~/.token-scrooge/.env`, and if the crew has no key of its own it falls through to Scrooge's. That
@@ -304,7 +309,10 @@ STALE with an "aged out" note instead of rotting silently, and todo tiles wear a
304
309
  day 7. A commit closes the session's focus card and the two link both ways. `trantor doctor`
305
310
  cross-checks every hub you know about against the per-project pins and reports any **split-brain**
306
311
  (a project live on two hubs) with the exact fix — and `trantor adopt <project>` migrates a project
307
- between hubs in one verified step, telling stale sessions to restart.
312
+ between hubs in one verified step, telling stale sessions to restart. A project's identity lives
313
+ in its checkout (`.trantor/project.json`, written by `trantor new`, `trantor connect` or
314
+ `trantor project <id>`), so renaming the directory keeps the board, the pin and the sessions; the
315
+ doctor names an orphaned identity and the one command that reclaims it.
308
316
 
309
317
  Crew output is gated mechanically, too: `bin/slop-gate.mjs` runs the vendored
310
318
  [anti-slop](https://github.com/dmmulroy/anti-slop) Oxlint rules over an agent's **changed files
@@ -397,7 +405,7 @@ rate, not work rate.
397
405
  | `relay_task_add(title, …, difficulty, model, deps, note?, project?)` | Cards with difficulty/model badges + DAG edges; `note` seeds the card's **permanent log**; `project` targets another board when you orchestrate from elsewhere |
398
406
  | `relay_task_move(id, status, note?)` | `todo → doing → testing → done` (the gate), `failed`, `blocked` — moves to testing/done should carry a `note`: what you did + the evidence, stored on the card forever |
399
407
  | `relay_task_check(id, index, done?)` | Tick one acceptance item on a card's checklist (seeded via `relay_task_add`'s `checklist`) — checked/total is the card's one honest progress denominator |
400
- | `relay_board` | The project's full board, as text |
408
+ | `relay_board` | The project's full board, as text — or `card:<id>` for one card, or `mine: true` for the calling session's own open cards (doing/testing/todo, newest first) |
401
409
  | `relay_scrooge(prompt, task?, difficulty?)` | Fractal cheap-model delegation, with the ledger receipt |
402
410
  | `relay_lesson(text, scope?)` | Record a failure lesson — auto-injected into all future crews |
403
411
  | `relay_handoff(summary)` | Full-window session succession |
package/bin/adopt.mjs CHANGED
@@ -13,9 +13,9 @@
13
13
  // newest, rather than asserting which one is yours.
14
14
  import { readdirSync, statSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
15
  import { execFileSync } from "node:child_process";
16
- import { join, dirname } from "node:path";
16
+ import { join, dirname, basename } from "node:path";
17
17
  import { homedir } from "node:os";
18
- import { resolveProject, writeOrchSession } from "../lib/project.mjs";
18
+ import { resolveProject, writeOrchSession, checkoutFor, devRootFor, readOrchSession } from "../lib/project.mjs";
19
19
 
20
20
  const D = "\x1b[2m", B = "\x1b[1m", Y = "\x1b[33m", G = "\x1b[32m", R = "\x1b[0m";
21
21
  const args = process.argv.slice(2);
@@ -23,29 +23,44 @@ const flag = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : n
23
23
 
24
24
  const project = args.find(a => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--session")
25
25
  || resolveProject(process.cwd());
26
- const devRoot = process.env.TRANTOR_DEV_ROOT || join(homedir(), "development");
27
- const dir = join(devRoot, project);
28
- if (!existsSync(dir)) {
29
- console.error(`no local checkout for ${project} (looked in ${devRoot})`);
26
+ // The checkout is found by the project's ID (#6724): a renamed directory still answers.
27
+ const dir = checkoutFor(project);
28
+ if (!dir) {
29
+ console.error(`no local checkout for ${project} (looked in ${devRootFor()})`);
30
30
  process.exit(1);
31
31
  }
32
32
 
33
33
  /** claude keeps a project's transcripts under a slug of its working directory. */
34
34
  const slug = dir.replace(/[/.]/g, "-");
35
- const tdir = join(homedir(), ".claude", "projects", slug);
36
- if (!existsSync(tdir)) {
35
+ const projectsDir = join(homedir(), ".claude", "projects");
36
+ const tdir = join(projectsDir, slug);
37
+ // A session that was running when the directory was renamed keeps writing under the OLD path's
38
+ // slug (#6724: "no transcript in the last hour" while the thread was 9 minutes fresh). The
39
+ // recorded orchestrator sid names that transcript wherever its slug lives, so it stays a candidate.
40
+ const recordedSid = readOrchSession(project);
41
+ const recordedTranscript = (() => {
42
+ if (!recordedSid) return "";
43
+ try {
44
+ for (const d of readdirSync(projectsDir)) {
45
+ const t = join(projectsDir, d, `${recordedSid}.jsonl`);
46
+ if (existsSync(t)) return t;
47
+ }
48
+ } catch {}
49
+ return "";
50
+ })();
51
+ if (!existsSync(tdir) && !recordedTranscript) {
37
52
  console.error(`no claude sessions have ever run in ${dir}`);
38
53
  process.exit(1);
39
54
  }
40
55
 
41
56
  const RECENT_MS = 60 * 60 * 1000;
42
57
  const now = Date.now();
43
- const candidates = readdirSync(tdir)
44
- .filter(f => f.endsWith(".jsonl"))
45
- .map(f => {
46
- const p = join(tdir, f);
58
+ const transcripts = existsSync(tdir) ? readdirSync(tdir).filter(f => f.endsWith(".jsonl")).map(f => join(tdir, f)) : [];
59
+ if (recordedTranscript && !transcripts.includes(recordedTranscript)) transcripts.push(recordedTranscript);
60
+ const candidates = transcripts
61
+ .map(p => {
47
62
  const st = statSync(p);
48
- return { id: f.replace(/\.jsonl$/, ""), mtime: st.mtimeMs, size: st.size };
63
+ return { id: basename(p, ".jsonl"), mtime: st.mtimeMs, size: st.size };
49
64
  })
50
65
  .filter(c => now - c.mtime < RECENT_MS)
51
66
  .sort((a, b) => b.mtime - a.mtime);
package/bin/advise.mjs CHANGED
@@ -1,38 +1,25 @@
1
1
  #!/usr/bin/env node
2
- // The Advisor — the brain's front door. Given work packages, decide HOW to execute:
3
- // solo | scrooge | crew | hybrid from task shape × plan economics × context horizon.
4
- //
2
+ // The Advisor — the brain's front door: given work packages, decide HOW to execute (solo |
3
+ // scrooge | crew | hybrid) from task difficulty × plan economics × context horizon.
5
4
  // echo '{"task":"build X","packages":[{"title":"engine","difficulty":"hard"},…]}' | node bin/advise.mjs
6
- // node bin/advise.mjs --demo # canned example
7
- //
8
- // Reads (all read-only):
9
- // ~/.agent-bus/profile.json — the user's declared plans (bin/profile.mjs)
10
- // ~/.token-scrooge/registry.json — Scrooge's models {cost_in, cost_out, good_for}
11
- // ~/.token-scrooge/capabilities.json — per-model quality scores
12
- // Exposed to agents as the MCP tool `relay_advise`; the crew skill calls it at kickoff.
5
+ // Reads profile.json and Scrooge's registry + capabilities (read-only); exposed as `relay_advise`.
13
6
  import { readFileSync, existsSync } from "node:fs";
14
7
  import { join } from "node:path";
15
8
  import { homedir } from "node:os";
16
9
  import { execSync } from "node:child_process";
17
10
  import { pathToFileURL } from "node:url";
18
- import { busDir, readConfig } from "../lib/project.mjs";
11
+ import { busDir, readConfig, resolveProject } from "../lib/project.mjs";
12
+ import { loadCatalog, lookup as catalogLookup, effortParams, UNCATALOGUED_STATUS } from "../lib/model-catalog.mjs";
13
+ import { benchedAt, loadSeatRecord } from "../lib/seat-record.mjs";
14
+ import { openStore } from "../lib/secrets.mjs";
19
15
 
20
16
  const H = homedir();
21
17
  const read = (p, fb) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return fb; } };
22
18
 
23
19
  // ---- crew roster: BUILT-IN seats + ANY opencode provider the user has brought (BYOM) ----
24
- // Each seat: the CLI binary that must exist (`cli`) · the `trantor up` LAUNCH spec · the bus
25
- // SESSION label (its identity on the board) · the profile PROVIDER key (tier/cost) · for
26
- // opencode-driven seats, the opencode PROVIDER id (`providerOc`) used to enumerate models + auth.
27
- //
28
- // GEMINI is deliberately absent: Google retired the free CLI seat (2026-06-18) → `gemini --yolo`
29
- // crashes exit 1. Its replacement is GLM via opencode.
30
- //
31
- // The opencode-driven seats are the BYOM substrate: opencode is a UNIVERSAL adapter, so any
32
- // provider the user configures in opencode (or declares in their profile) becomes a crew seat
33
- // with ZERO code change here — `buildRoster()` discovers them at runtime. The built-ins below are
34
- // just the curated defaults + the two opencode seats with non-obvious mappings (glm: profile key
35
- // `zai` ↔ opencode provider `zai-coding-plan`).
20
+ // Each seat: the CLI binary (`cli`), the `trantor up` LAUNCH spec, the bus SESSION label, the
21
+ // profile PROVIDER key and, for opencode seats, the opencode provider id (`providerOc`). Gemini is
22
+ // absent (its free CLI seat was retired; GLM via opencode replaced it); buildRoster() finds the rest.
36
23
  export const BUILTIN_ROSTER = {
37
24
  codex: { cli: "codex", launch: "codex", session: "codex", provider: "codex" },
38
25
  kimi: { cli: "kimi", launch: "kimi", session: "kimi", provider: "kimi" },
@@ -45,11 +32,10 @@ export const BUILTIN_ROSTER = {
45
32
  const BUILTIN_OC = new Set(Object.values(BUILTIN_ROSTER).filter(s => s.providerOc).map(s => s.providerOc));
46
33
  const NEVER_DISCOVER = new Set(["claude", "codex", "kimi", "gemini", "zai", "opencode"]);
47
34
 
48
- // Discover opencode providers the user has configured — from opencode.json `provider` keys AND
49
- // from profile providers declared via `trantor provider add` that aren't already built-in. Each
50
- // becomes an opencode-driven seat under its OWN bus label (distinct session, no collisions). THIS
51
- // is what lets a brought provider (Inception, a Japanese model, any opencode vendor) light up a
52
- // seat with no code edit. T2's capability ingestion then makes it route well by difficulty.
35
+ // Discover opencode providers the user configured (opencode.json `provider` keys and profile
36
+ // providers from `trantor provider add`) that aren't built in. Each becomes an opencode-driven
37
+ // seat under its OWN bus label, so a brought provider lights up a seat with no code edit; T2's
38
+ // capability ingestion then makes it route well by difficulty.
53
39
  export function discoverSeats(profile, ocConfig) {
54
40
  const out = {};
55
41
  const provKeys = new Set([...Object.keys(ocConfig?.provider || {}), ...Object.keys(profile?.providers || {})]);
@@ -81,7 +67,7 @@ export function loadWorld() {
81
67
  const opencodeKey = (prov) => !!ocConfig?.provider?.[prov]?.options?.apiKey;
82
68
  // a key the user already has for Scrooge counts too — the opencode runner sources these .env
83
69
  // files, so e.g. OPENROUTER_API_KEY in ~/.token-scrooge/.env lights up the seat with no extra setup.
84
- const envHasKey = (k) => !!process.env[k] || [join(H, ".token-scrooge", ".env"), join(H, ".agent-bus", ".env")]
70
+ const envHasKey = (k) => !!process.env[k] || openStore().has(k) || [join(H, ".token-scrooge", ".env"), join(H, ".agent-bus", ".env")]
85
71
  .some(f => { try { return readFileSync(f, "utf8").includes(k); } catch { return false; } });
86
72
  // a seat is available only if its CLI exists AND (for opencode-driven seats) the provider is
87
73
  // actually set up — a present binary with a dead/missing seat must NOT be recommended.
@@ -119,7 +105,7 @@ const FORECAST = { easy: 0.3e6, medium: 1.5e6, hard: 6e6 }; // tokens
119
105
  const CREW_PREF = { hard: ["codex", "glm", "kimi", "deepseek", "openrouter"], medium: ["kimi", "glm", "codex", "deepseek", "openrouter"], easy: ["deepseek", "kimi", "glm", "codex", "openrouter"] };
120
106
 
121
107
  export function advise(input, world = loadWorld()) {
122
- const { profile, registry, caps, agents, scrooge, roster = BUILTIN_ROSTER } = world;
108
+ const { profile, registry, caps, agents, scrooge, roster = BUILTIN_ROSTER, record } = world;
123
109
  // brought (discovered) opencode providers extend the preference list — appended LAST in every
124
110
  // tier (unknown strength a priori, like openrouter), so they fill once the curated seats are
125
111
  // taken, and are the only option for a user who brought nothing but a custom provider.
@@ -148,17 +134,39 @@ export function advise(input, world = loadWorld()) {
148
134
 
149
135
  // ---- routing per package ----
150
136
  const used = {};
137
+ // #7762: seats benched by the record ride along so the recommendation says WHO was benched,
138
+ // at which difficulty, and what their producing nothing already cost (tokens the crew burned).
139
+ const seatFeedback = [];
151
140
  const routing = pkgs.map(p => {
152
141
  if ((mode === "hybrid" || mode === "scrooge") && p.difficulty === "easy" && scrooge) {
153
142
  const m = scroogeModelFor(registry, caps, p.kind, p.difficulty);
154
143
  const tok = FORECAST.easy;
155
144
  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,
145
+ // #7777: scores pick WHICH model; the catalog says HOW to call it — attach the per-difficulty
146
+ // request parameters so the executor does not have to look them up again.
147
+ const catEntry = catalogLookup(m?.model);
148
+ const catParams = catEntry.found ? effortParams(m.model, p.difficulty, "openai-chat") : null;
149
+ const effort = catEntry.found
150
+ ? { found: true, difficulty: p.difficulty, api: "openai-chat", params: catParams || {} }
151
+ : { found: false, difficulty: p.difficulty, params: null, status: UNCATALOGUED_STATUS };
152
+ return { ...p, executor: "scrooge", model: m?.model, pool: "api", est_cost_usd: cost, effort,
157
153
  reason: `easy + stateless → cheapest capable model (${m?.model}); not worth a crew seat` };
158
154
  }
159
155
  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
156
  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));
157
+ const prefAll = [...CREW_PREF[p.difficulty], ...broughtPref].filter(a => agents.includes(a));
158
+ // #7762 feedback loop: a seat whose last 3 cards AT THIS DIFFICULTY in THIS project were
159
+ // empty/bounced is benched at that difficulty — the redone work counts against it, so it
160
+ // stops being "the cheap option". A bench never empties the pool (better a struck seat
161
+ // than no seat), and the record is per project — never global.
162
+ const struck = prefAll.map(a => [a, benchedAt(record, a, p.difficulty)]).filter(([, b]) => b);
163
+ const eligible = prefAll.filter(a => !benchedAt(record, a, p.difficulty));
164
+ const pref = eligible.length ? eligible : prefAll;
165
+ for (const [s, b] of struck) {
166
+ const wasted = b.wastedTokens ? ` ≈${(b.wastedTokens / 1e6).toFixed(1)}M tok burned` : "";
167
+ seatFeedback.push({ seat: s, difficulty: p.difficulty, streak: b.streak, cardIds: b.cardIds, wastedTokens: b.wastedTokens,
168
+ note: `${s} benched at ${p.difficulty}: last ${b.streak.length} cards ${b.streak.join("/")} (#${b.cardIds.join(" #")})${wasted}` });
169
+ }
162
170
  const agent = pref.sort((a, b) => (used[a] || 0) - (used[b] || 0))[0] || agents[0] || "deepseek";
163
171
  used[agent] = (used[agent] || 0) + 1;
164
172
  const pool = tierOf(profile, roster[agent]?.provider || agent);
@@ -176,6 +184,7 @@ export function advise(input, world = loadWorld()) {
176
184
  // has scored it (AA scores + price proxy + per-difficulty cost weighting → hard escalates to a
177
185
  // strong model, easy stays cheap). If it hasn't been run, routing falls back to cost-only.
178
186
  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>)`;
187
+ 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
188
  return { ...p, executor: agent, pool, est_cost_usd: est, reason: why_r };
180
189
  });
181
190
  // crew-size rationale: seats are EMERGENT from the work, and we say so
@@ -188,11 +197,14 @@ export function advise(input, world = loadWorld()) {
188
197
 
189
198
  const apiCost = +(routing.reduce((s, r) => s + (r.est_cost_usd || 0), 0)).toFixed(2);
190
199
  const pools = [...new Set(routing.map(r => `${r.executor}:${r.pool}`))];
200
+ // One bench line per seat+difficulty, however many packages tripped it.
201
+ const feedback = [...new Map(seatFeedback.map(f => [`${f.seat}@${f.difficulty}`, f])).values()];
191
202
  const summary =
192
203
  `Recommendation: ${mode.toUpperCase()}. ${why.join("; ")}. ` +
193
204
  (mode === "crew" || mode === "hybrid"
194
205
  ? `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).`
206
+ `Estimated real-money cost ≈ $${apiCost} (everything on a subscription pool is $0 marginal — quota pooling across ${pools.length} pools).` +
207
+ (feedback.length ? ` Seat record (this project): ${feedback.map(f => f.note).join("; ")}.` : "")
196
208
  : "");
197
209
  const table = ["| package | diff | executor (model) | pool | est $ | reason |", "|---|---|---|---|---|---|",
198
210
  ...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 +233,12 @@ export function advise(input, world = loadWorld()) {
221
233
  ? routing.map((x, j) => j + 1).filter(j => j !== i + 1)
222
234
  : (r.executor !== "orchestrator" ? foundationIdx.filter(f => f !== i + 1) : []) };
223
235
  });
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 };
236
+ // #7777: crew-bound packages get their catalog effort attached at SPAWN, when the live model is
237
+ // known (bin/crew/models.mjs resolveSpec → CREW_EFFORT); the advisor only records that it is deferred.
238
+ const catalogMeta = (() => { const c = loadCatalog(); return { version: c.version, models: Object.keys(c.models).length, source: "configs/model-catalog.json" }; })();
239
+ 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 };
240
+ if (feedback.length) recommendation.seat_feedback = feedback;
241
+ return recommendation;
225
242
  }
226
243
 
227
244
  // ---- CLI ----
@@ -238,6 +255,10 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
238
255
  const stdin = readFileSync(0, "utf8").trim();
239
256
  input = stdin ? JSON.parse(stdin) : { packages: [] };
240
257
  }
241
- const out = advise(input);
258
+ const world = loadWorld();
259
+ // #7762: fold this project's seat record into the routing (fail-open — no hub, no record,
260
+ // and the advice is exactly what it was before the feedback loop existed).
261
+ try { world.record = await loadSeatRecord({ project: resolveProject(process.cwd()) }); } catch {}
262
+ const out = advise(input, world);
242
263
  console.log(JSON.stringify(out, null, 2));
243
264
  }
package/bin/app.mjs CHANGED
@@ -1,24 +1,18 @@
1
1
  #!/usr/bin/env node
2
- // trantor app — install/update the Trantor DESKTOP APP (Tauri) from GitHub Releases.
3
- //
4
- // The npm package deliberately does NOT ship desktop/ (a 6MB DMG has no business in node_modules);
5
- // the app travels as a GitHub Release asset instead. This command is the whole distribution story
6
- // for a teammate: `npm i -g trantor && trantor app install` → latest DMG lands in /Applications.
7
- //
8
- // trantor app status: installed version vs latest release
9
- // trantor app install download the latest release DMG and install to /Applications
10
- // trantor app update same as install (re-pulls whatever is latest)
11
- //
2
+ // trantor app — install/update the Trantor DESKTOP APP (Tauri) from GitHub Releases. The npm
3
+ // package does not ship desktop/ (a 6MB DMG has no business in node_modules); the app travels as
4
+ // a GitHub Release asset, so `npm i -g trantor && trantor app install` is the whole story.
5
+
12
6
  // Release side (maintainer): build the DMG (cd desktop && npm run tauri build), then
13
7
  // gh release create app-v<ver> desktop/src-tauri/target/release/bundle/dmg/Trantor_<ver>_aarch64.dmg
14
- // Any release whose assets include a Trantor_*.dmg is an app release; the newest one wins, so app
15
- // releases interleave freely with code (npm) releases.
16
- import { execFileSync } from "node:child_process";
8
+ // Any release whose assets include a Trantor_*.dmg is an app release; the newest one wins.
9
+ import { execFileSync, spawn } from "node:child_process";
17
10
  import { createWriteStream, existsSync, rmSync } from "node:fs";
18
11
  import { Readable } from "node:stream";
19
12
  import { pipeline } from "node:stream/promises";
20
13
  import { join } from "node:path";
21
14
  import { tmpdir } from "node:os";
15
+ import { cleanLaunchEnv } from "../lib/launch-env.mjs";
22
16
 
23
17
  const REPO = "sashabogi/trantor";
24
18
  const APP = "/Applications/Trantor.app";
@@ -27,11 +21,39 @@ const cmd = process.argv[2] || "status";
27
21
 
28
22
  if (process.platform !== "darwin") { console.error("trantor app: the desktop app is macOS-only for now"); process.exit(1); }
29
23
  if (!["status", "install", "update"].includes(cmd)) {
30
- console.error("usage: trantor app [status|install|update]"); process.exit(1);
24
+ console.error([
25
+ "usage: trantor app [status|install|update]",
26
+ " status installed version vs latest release (default)",
27
+ " install download the latest release DMG and install to /Applications",
28
+ " update same as install, then relaunch the app from a clean env",
29
+ ].join("\n"));
30
+ process.exit(1);
31
31
  }
32
32
 
33
33
  function sh(file, args) { return execFileSync(file, args, { encoding: "utf8" }); }
34
34
 
35
+ function appRunning() {
36
+ try { return sh("/usr/bin/pgrep", ["-x", "Trantor"]).trim() !== ""; }
37
+ catch { return false; }
38
+ }
39
+
40
+ function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
41
+
42
+ // `open` hands the caller's environment to the app, so an update run from a badged crew pane would
43
+ // badge every child the new app spawns and its hand-offs would reattach to the wrong project (#7414).
44
+ function relaunch(wasRunning) {
45
+ if (wasRunning) {
46
+ try { sh("/usr/bin/osascript", ["-e", 'tell application "Trantor" to quit']); } catch {}
47
+ const deadline = Date.now() + 10000;
48
+ while (appRunning() && Date.now() < deadline) pause(200);
49
+ if (appRunning()) { try { sh("/usr/bin/pkill", ["-x", "Trantor"]); } catch {} pause(500); }
50
+ }
51
+ const child = spawn("/usr/bin/open", ["-a", APP], { env: cleanLaunchEnv(), stdio: "ignore", detached: true });
52
+ child.on("error", e => console.error(`relaunch failed: ${e.message}`));
53
+ child.unref();
54
+ console.log(`↻ ${wasRunning ? "quit the old app and " : ""}launched ${APP} from a clean env`);
55
+ }
56
+
35
57
  function installedVersion() {
36
58
  try { return sh("plutil", ["-extract", "CFBundleShortVersionString", "raw", join(APP, "Contents/Info.plist")]).trim(); }
37
59
  catch { return ""; }
@@ -76,12 +98,14 @@ if (!dl.ok || !dl.body) { console.error(`download failed: HTTP ${dl.status}`); p
76
98
  await pipeline(Readable.fromWeb(dl.body), createWriteStream(dmg));
77
99
 
78
100
  let mount = "";
101
+ let installed = false;
102
+ const wasRunning = appRunning();
79
103
  try {
80
104
  // diskutil first: on macOS 26 the deprecated hdiutil shim IGNORES -nobrowse, so the mounted
81
- // volume popped a Finder window mid-update and read as an install prompt (2026-08-27). Parse
105
+ // volume popped a Finder window mid-update and read as an install prompt. Parse
82
106
  // the mount point as everything after the last " at " — volume names can contain spaces.
83
107
  try {
84
- // real output (verified 2026-08-27): tab-separated, same shape as hdiutil —
108
+ // real output (verified live): tab-separated, same shape as hdiutil —
85
109
  // "/dev/disk12s1\tApple_HFS \t/Volumes/Trantor" — last tab field is the mount.
86
110
  const out = sh("diskutil", ["image", "attach", "--mountOptions", "nobrowse", "--readOnly", dmg]);
87
111
  const line = out.trim().split("\n").filter(l => l.includes("/Volumes/")).pop() || "";
@@ -100,6 +124,7 @@ try {
100
124
  // Gatekeeper doesn't refuse the unsigned build on first launch.
101
125
  try { sh("xattr", ["-dr", "com.apple.quarantine", APP]); } catch {}
102
126
  console.log(`✓ Trantor.app ${installedVersion() || rel.version} installed → ${APP}`);
127
+ installed = true;
103
128
  } catch (e) {
104
129
  console.error(`install failed: ${e.message}`); process.exitCode = 1;
105
130
  } finally {
@@ -109,3 +134,5 @@ try {
109
134
  }
110
135
  try { rmSync(dmg, { force: true }); } catch {}
111
136
  }
137
+ // A replaced app keeps running its deleted binary until relaunched; `update` always relaunches.
138
+ if (installed && (cmd === "update" || wasRunning)) relaunch(wasRunning);
package/bin/cli.mjs CHANGED
@@ -23,6 +23,7 @@ switch (cmd) {
23
23
  case "connect": run("bin/connect.mjs"); break;
24
24
  case "profile": run("bin/profile.mjs"); break;
25
25
  case "provider": case "providers": run("bin/provider.mjs"); break;
26
+ case "secrets": run("bin/secrets.mjs"); break;
26
27
  case "models": run("bin/models.mjs"); break;
27
28
  case "advise": run("bin/advise.mjs"); break;
28
29
  case "verify": run("bin/crew-verify.mjs"); break;
@@ -71,6 +72,34 @@ switch (cmd) {
71
72
  }
72
73
  run("hub.mjs"); break;
73
74
  }
75
+ case "project": {
76
+ // The checkout's identity (#6724): show where this project's name comes from, or record an id
77
+ // in .trantor/project.json so a directory rename cannot orphan the pin, board and sessions.
78
+ const { resolveProjectInfo, resolveHubInfo, gitRoot, readProjectId, writeProjectId, isProjectId, PROJECT_MARKER } = await import(join(ROOT, "lib/project.mjs"));
79
+ const { basename } = await import("node:path");
80
+ const id = args.find(a => !a.startsWith("--"));
81
+ const root = gitRoot(process.cwd()) || process.cwd();
82
+ const label = basename(root);
83
+ if (!id) {
84
+ const { project, via } = resolveProjectInfo(process.cwd());
85
+ const hub = resolveHubInfo(project);
86
+ const marked = readProjectId(root);
87
+ console.log(`project: ${project} (via ${via})`);
88
+ console.log(`directory: ${label}${marked && marked !== label ? " (a label — the id is recorded in the checkout)" : ""}`);
89
+ console.log(`marker: ${marked ? `${PROJECT_MARKER} → ${marked}` : `none — trantor project ${project} records one, so a rename cannot orphan the identity`}`);
90
+ console.log(`hub: ${hub.url} (via ${hub.via})`);
91
+ break;
92
+ }
93
+ if (!isProjectId(id)) { console.error(`error: "${id}" is not a project id (letters, digits, . _ - ; 80 max)`); process.exit(1); }
94
+ const current = readProjectId(root);
95
+ if (current && current !== id && !args.includes("--force")) {
96
+ console.error(`refused: ${root} is recorded as "${current}" — pass --force to re-claim it as "${id}" (the old name's board, pin and session rows will NOT follow)`);
97
+ process.exit(1);
98
+ }
99
+ const p = writeProjectId(root, id, "trantor project");
100
+ console.log(`${id} recorded in ${p}${current === id ? " (unchanged)" : ""} — commit it so worktrees and clones carry the identity`);
101
+ break;
102
+ }
74
103
  case "watch": run("bin/relay-watch.mjs"); break;
75
104
  case "catchup": run("bin/catchup.mjs"); break;
76
105
  case "agents": run("bin/agents.mjs"); break;
@@ -99,6 +128,7 @@ switch (cmd) {
99
128
  case "state": run("bin/state.mjs"); break;
100
129
  case "seats": case "seat": run("bin/seats.mjs"); break;
101
130
  case "seat-why": case "why": run("bin/seat-why.mjs"); break;
131
+ case "seat-record": run("bin/seat-record.mjs"); break;
102
132
  case "orchestrate": run("bin/orchestrate.mjs"); break;
103
133
  case "app": run("bin/app.mjs"); break;
104
134
  case "patrol": run("bin/patrol.mjs"); break;
@@ -201,6 +231,7 @@ switch (cmd) {
201
231
  trantor connect (re)wire every installed AI CLI to the bus
202
232
  trantor profile declare your plans: trantor profile set claude=max codex=plus deepseek=api
203
233
  trantor provider bring ANY model (BYOM): list · status [--json] · verify <name> --key … · add <name> --key … · remove <name>
234
+ trantor secrets provider keys in the OS keychain: list · set <NAME> (stdin) · remove <NAME> · migrate [--dry-run]
204
235
  trantor models browse live models behind each seat + the router's pick per difficulty
205
236
  trantor up … spawn a crew here: trantor up codex kimi deepseek:deepseek glm:zai-coding-plan
206
237
  trantor open host THIS session as the project's orchestrator pane (trantor down spares it)
@@ -226,11 +257,13 @@ switch (cmd) {
226
257
  trantor recost recompute sub-agent notional cost from on-disk transcripts + reseed the board (repair after upgrade) — [--dry-run]
227
258
  trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
228
259
  trantor advise ask the Advisor directly (JSON on stdin; --demo to see it)
260
+ trantor project this checkout's identity: project [<id>] — records .trantor/project.json so a directory rename cannot orphan the board, pin and sessions
229
261
  trantor hub run the hub in the foreground (setup installs it as a service instead)
230
262
  …or manage per-project hub pins: hub list · hub set <project> <url> · hub unset <project>
231
263
  seats: which project lives in which directory — seats · seats add · seats up · seats login install
232
264
  trantor state a seat's working memory: show <seat> <card> [--json] · validate · reset --force · gc [--apply]
233
265
  trantor seat-why WHY a seat is down (err file, logs, pids): seat-why <agent> [--json] — quota, auth, crash, or just no pane
266
+ trantor seat-record the per-project seat record the advisor benches from (✓/∅/↩ per difficulty) — [--project p] [--reset <seat>] [--json]
234
267
  trantor watch live bus feed in the terminal
235
268
  trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
236
269
  trantor policy the autonomy ladder: show | set <project> <1-4> | link <a> <b> --reason "<why>"
package/bin/connect.mjs CHANGED
@@ -7,7 +7,7 @@ import { join, dirname } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { execSync } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
- import { resolveProject, resolveHubInfo } from "../lib/project.mjs";
10
+ import { resolveProjectInfo, resolveHubInfo, gitRoot, writeProjectId, PROJECT_MARKER } from "../lib/project.mjs";
11
11
 
12
12
  const DRY = process.argv.includes("--dry-run");
13
13
  const MCP = join(dirname(dirname(fileURLToPath(import.meta.url))), "mcp.mjs");
@@ -15,7 +15,8 @@ const MCP = join(dirname(dirname(fileURLToPath(import.meta.url))), "mcp.mjs");
15
15
  // to and the hub THAT project resolves to. Some CLIs spawn MCP with a scrubbed env where even `git`
16
16
  // is missing, so the stamp is the belt; the worktree path rule in lib/project.mjs stays primary.
17
17
  // Env wins in resolveHubInfo, so these keys are REFRESHED on every connect run — re-run after a pin change.
18
- const PROJECT_AT_CONNECT = resolveProject(process.cwd());
18
+ const PROJECT_INFO = resolveProjectInfo(process.cwd());
19
+ const PROJECT_AT_CONNECT = PROJECT_INFO.project;
19
20
  const URL_ = resolveHubInfo(PROJECT_AT_CONNECT).url;
20
21
  // Graft (github.com/NanoNets/context-graph-engine): local Tree-sitter dependency graph over MCP,
21
22
  // wired next to `relay` so a seat locates code in one call; the graph refreshes itself per query
@@ -110,15 +111,28 @@ if (has("gemini")) {
110
111
  }), p);
111
112
  }
112
113
 
113
- // ---- Kimi CLI ---- (same refresh: the stale entry that caused #7893 was {RELAY_AGENT: kimi} only)
114
- if (has("kimi")) {
115
- const p = join(homedir(), ".kimi", "mcp.json");
116
- report("kimi", patchJson(p, d => {
117
- d.mcpServers ||= {};
118
- d.mcpServers.relay ||= { command: "node", args: [MCP], env: {} };
119
- d.mcpServers.relay.env = { ...d.mcpServers.relay.env, ...relayEnv("kimi") };
120
- if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
121
- }), p);
114
+ // ---- Kimi CLI + kimi-code ---- (same refresh: the stale entry that caused #7893 was {RELAY_AGENT: kimi} only)
115
+ // kimi-code is a separate install reading ~/.kimi-code/mcp.json — wiring only the old file left the
116
+ // running kimi seat on a hard-coded stale hub (#7938). Same shape and stamp; the dir is detected by
117
+ // config.toml presence — existsSync only, the config itself is never read.
118
+ const kimiRelay = (cli, p) => report(cli, patchJson(p, d => {
119
+ d.mcpServers ||= {};
120
+ d.mcpServers.relay ||= { command: "node", args: [MCP], env: {} };
121
+ d.mcpServers.relay.env = { ...d.mcpServers.relay.env, ...relayEnv("kimi") };
122
+ if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
123
+ }), p);
124
+ const kimiPaths = [join(homedir(), ".kimi", "mcp.json"), join(homedir(), ".kimi-code", "mcp.json")];
125
+ const kimiWritten = new Set();
126
+ if (has("kimi")) { kimiRelay("kimi", kimiPaths[0]); kimiWritten.add(kimiPaths[0]); }
127
+ if (existsSync(join(homedir(), ".kimi-code", "config.toml"))) { kimiRelay("kimi-code", kimiPaths[1]); kimiWritten.add(kimiPaths[1]); }
128
+ // A kimi-family config this run did NOT write still names a hub of its own; if it disagrees with
129
+ // the pin, say so — one CLI of the pair would keep registering on a different bus (#7938).
130
+ for (const p of kimiPaths) {
131
+ if (kimiWritten.has(p) || !existsSync(p)) continue;
132
+ try {
133
+ const u = JSON.parse(readFileSync(p, "utf8"))?.mcpServers?.relay?.env?.RELAY_URL;
134
+ if (u && u !== URL_) report("kimi", `WARN: ${p} still points at ${u} — re-run connect or refresh it by hand`);
135
+ } catch {}
122
136
  }
123
137
 
124
138
  // ---- OpenCode ----
@@ -247,6 +261,17 @@ ${HAS_GRAFT ? ` - id: trantor-graft
247
261
  }
248
262
 
249
263
  const found = out.length;
264
+ // The checkout records its id at connect time (#6724) when the name came from the directory, so a
265
+ // later rename carries the pin, board and sessions along. Never from RELAY_PROJECT or a seat
266
+ // worktree path: a badge must not stamp its name into somebody else's repo.
267
+ {
268
+ const root = gitRoot(process.cwd());
269
+ if (PROJECT_INFO.via === "marker") report("project", `id ${PROJECT_AT_CONNECT} already recorded in ${PROJECT_MARKER}`);
270
+ else if (root && PROJECT_INFO.via === "git") {
271
+ if (!DRY) writeProjectId(root, PROJECT_AT_CONNECT, "trantor connect");
272
+ report("project", `id ${PROJECT_AT_CONNECT} recorded in ${PROJECT_MARKER} — commit it so worktrees and clones carry the identity`, root);
273
+ }
274
+ }
250
275
  console.log(`trantor connect${DRY ? " (dry run)" : ""} — project: ${PROJECT_AT_CONNECT}, hub: ${URL_}`);
251
276
  for (const r of out) console.log(` ${r.cli.padEnd(9)} ${r.status}${r.detail ? ` (${r.detail})` : ""}`);
252
277
  if (!found) console.log(" no supported CLIs found on PATH (claude, codex, gemini, kimi, opencode, dsh)");
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) {