trantor 0.18.40 → 0.18.41

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.40",
3
+ "version": "0.18.41",
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/bin/cli.mjs CHANGED
@@ -41,6 +41,9 @@ switch (cmd) {
41
41
  const cfg = readConfig();
42
42
  const here = resolveProject();
43
43
  console.log(`global default: ${cfg.url || DEFAULT_HUB_URL}${cfg.url ? "" : " (built-in)"}`);
44
+ // SAFETY: cfg.hubs is the config.json hubs map decoded by JSON.parse; the check separates
45
+ // "a pin map" (any object, even field-less) from primitives/null (no pins to list).
46
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
44
47
  const hubs = cfg.hubs && typeof cfg.hubs === "object" ? Object.entries(cfg.hubs) : [];
45
48
  if (!hubs.length) console.log("no per-project pins — every project uses the global default");
46
49
  for (const [p, u] of hubs) console.log(`${p === here ? "*" : " "} ${p} → ${u}`);
@@ -192,7 +195,7 @@ switch (cmd) {
192
195
  trantor doctor where do I stand? hub/plugin/CLIs/auth/keys/profile, with copy-paste fixes
193
196
  trantor connect (re)wire every installed AI CLI to the bus
194
197
  trantor profile declare your plans: trantor profile set claude=max codex=plus deepseek=api
195
- trantor provider bring ANY model (BYOM): list seats · add <name> --key … · remove <name>
198
+ trantor provider bring ANY model (BYOM): list · status [--json] · verify <name> --key … · add <name> --key … · remove <name>
196
199
  trantor models browse live models behind each seat + the router's pick per difficulty
197
200
  trantor up … spawn a crew here: trantor up codex kimi deepseek:deepseek glm:zai-coding-plan
198
201
  trantor open host THIS session as the project's orchestrator pane (trantor down spares it)
@@ -534,6 +534,10 @@ async function reportHealthy() {
534
534
  const TURN_MAX_MS = Math.max(0, Number(process.env.TRANTOR_TURN_MAX_MS || 20 * 60 * 1000));
535
535
  const TIME_BOX_PROMPT = "your previous turn was cut at the time box; commit what is done, move the card with a note, report in one line";
536
536
  let inFollowUp = false;
537
+ // #6289: whether the turn that just ended was CUT at the time box. The follow-up recursion
538
+ // overwrites it with its own state, so a caller reading it after the chain sees the state of the
539
+ // LAST turn — which is what decides whether a failed chain died to the box or to the API.
540
+ let lastTurnCut = false;
537
541
  // The card the CURRENT CLI session belongs to (#6134). 0 = the kickoff session, which belongs to
538
542
  // no card, so the first contract that names one starts a session of its own.
539
543
  let sessionCard = 0;
@@ -703,6 +707,7 @@ exit $turn_exit`;
703
707
  try { unlinkSync(CUTF); } catch {}
704
708
  log(`\x1b[33mturn cut at the ${Math.round(TURN_MAX_MS / 1000)}s time box — CLI and every descendant ended${boxed ? "" : " (node backstop: bash itself was wedged)"}\x1b[0m`);
705
709
  }
710
+ lastTurnCut = cut;
706
711
  // #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
707
712
  // wait above drains the live stderr scrubber first; this pass is defense in depth for redaction.
708
713
  try { writeFileSync(ERRF, redactKeys(readFileSync(ERRF, "utf8"))); } catch {}
@@ -757,8 +762,11 @@ exit $turn_exit`;
757
762
  // #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
758
763
  // never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
759
764
  const tokens = parseTurnTokens(ownOut);
760
- const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, verdict };
761
- if (tokens) telemetryRow.tokens = tokens;
765
+ // #6289: every ledger row names in ONE field what happened to the turn cut (the box ended it),
766
+ // api-error (the CLI failed), completed — and what it cost in tokens, even when this CLI printed
767
+ // no usage line (0 means "not reported", never "free"). `cut` stays too: the drills read it.
768
+ const outcome = cut ? "cut" : (effExit !== 0 ? "api-error" : "completed");
769
+ const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, verdict, outcome, tokens };
762
770
  if (cut) telemetryRow.cut = true;
763
771
  telemetry(telemetryRow);
764
772
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
@@ -988,8 +996,13 @@ function askedExcerpt(message) {
988
996
  // #6228: a wake naming another project (its sender's home project, not this seat's, and the
989
997
  // two are not `trantor policy link`ed) is dropped without acting — never queued, never folded
990
998
  // into context. One report goes back to the sender so it does not just look like silence.
999
+ // The hub's OWN agents (`hub:duty` et al.) are exempt: they speak for this hub's projects,
1000
+ // not a foreign one, and fencing them made every seat deaf to #5760's actionable
1001
+ // file-conflict warnings — the same class of pseudo-id notifyAssigners already treats
1002
+ // specially. Found by test-failure.mjs (#6301): the fence refused the drill's duty-agent
1003
+ // wake exactly as it refused real cross-project traffic.
991
1004
  const links = wakeCandidates.length ? await currentLinks() : [];
992
- const crossProject = wakeCandidates.filter(m => !isLinkedProject(senderProjectOf(m.from), PROJ, links));
1005
+ const crossProject = wakeCandidates.filter(m => !String(m.from || "").startsWith("hub:") && !isLinkedProject(senderProjectOf(m.from), PROJ, links));
993
1006
  for (const m of crossProject) {
994
1007
  const sp = senderProjectOf(m.from) || "?";
995
1008
  log(`\x1b[33mcross-project wake dropped\x1b[0m — ${m.from} (${sp}) is not ${PROJ}'s project and the two are not linked`);
@@ -1072,10 +1085,18 @@ function askedExcerpt(message) {
1072
1085
  }
1073
1086
  savePending(pendingWake, pendingBcast);
1074
1087
  await reportFailure(ec, "message", pendingWake.length, reason);
1075
- if (PARKING_REASONS.has(reason)) {
1076
- retryAt = await parkSeat(reason, pendingWake.length, quotaReset);
1088
+ // #6289: TWO consecutive exit-1 turns on one contract PARK the seat — no third attempt.
1089
+ // The burn this stops (card #6270, 2026-09-03): an exit-1 turn rode the redelivery ladder,
1090
+ // and every rung re-sent the SAME contract as a fresh full turn — the seat re-read and
1091
+ // re-did finished work, then died to the same API error or the box again; five cycles,
1092
+ // 4.7h, for a 34-line change. The first failure still retries; the second parks with a
1093
+ // reason (time-box when the chain died to box cuts, api-error otherwise), holds the queue,
1094
+ // and is woken again only by `trantor up` (a restart).
1095
+ const parkReason = PARKING_REASONS.has(reason) ? reason : (lastTurnCut ? "time-box" : "api-error");
1096
+ if (PARKING_REASONS.has(reason) || deliveryFails >= 2) {
1097
+ retryAt = await parkSeat(parkReason, pendingWake.length, quotaReset);
1077
1098
  await notifyAssigners(assigners,
1078
- `⛔ your contract is PARKED on ${SESSION} (${reason}) — not retrying · asked: "${asked}"`);
1099
+ `⛔ your contract is PARKED on ${SESSION} (${parkReason}) — not retrying · asked: "${asked}"`);
1079
1100
  lastTurnAt = Date.now();
1080
1101
  return;
1081
1102
  }
package/bin/crew.sh CHANGED
@@ -238,21 +238,29 @@ _herdr_close_pane() { [ "$DRY" = "1" ] && { echo "[dry] herdr pane close $1";
238
238
  # So the project gets ONE claude session id, chosen by us and remembered. First open starts claude
239
239
  # under it; every later open resumes it. Discovering the id afterwards would be guesswork, and
240
240
  # `--continue` would grab whatever ran last in this directory, which may be a different window.
241
- # A NAMED project must open in ITS checkout, wherever the caller stands (2026-08-31: the app ran
242
- # `trantor open crebral-health` from the Tauri process cwd and claude booted THERE a trust
243
- # prompt for a folder the operator never chose, transcripts under the wrong slug, no project
244
- # memory, ACTIVE NOW blind). Resolution mirrors the app's project_dir: $TRANTOR_DEV_ROOT
245
- # (default ~/development)/<name>. Unknown name from an unrelated cwd refuse loudly rather
246
- # than open somewhere silly.
247
- _orch_resolve_dir() { # $1=cwd $2=project-arg dir to open in (stdout); fails when unresolvable
248
- local herebase; herebase="$(basename "$(git -C "$1" rev-parse --show-toplevel 2>/dev/null || echo "$1")")"
249
- if [ -z "$2" ] || [ "$2" = "$herebase" ]; then printf '%s' "$1"; return 0; fi
241
+ # A NAMED project must open in ITS checkout (2026-08-31: the app ran `trantor open crebral-health`
242
+ # from the Tauri cwd and claude booted THERE). A caller already inside another git root or carrying
243
+ # another pane badge is a crossed identity, not permission to relocate silently (2026-09-03 twins).
244
+ # An unbadged caller outside a repo may resolve through $TRANTOR_DEV_ROOT (default ~/development).
245
+ _orch_resolve_dir() { # $1=cwd $2=project-arg dir to open in (stdout); fails on crossed identity
246
+ local target="${2:-$PROJ}" badge="${TRANTOR_ORCH:-${TRANTOR_SEAT:-}}" gitroot="" herebase=""
247
+ gitroot="$(git -C "$1" rev-parse --show-toplevel 2>/dev/null || true)"
248
+ [ -n "$gitroot" ] && herebase="$(basename "$gitroot")"
249
+ if [ -n "$badge" ] && [ "$badge" != "$target" ]; then
250
+ echo "trantor open: refused — this shell is badged for '$badge', not '$target'; open it from the target project's shell" >&2
251
+ return 1
252
+ fi
253
+ if [ -n "$herebase" ] && [ "$herebase" != "$target" ]; then
254
+ echo "trantor open: refused — cwd belongs to project '$herebase', not '$target'; cd to the target checkout first" >&2
255
+ return 1
256
+ fi
257
+ if [ -z "$2" ] || [ "$target" = "$herebase" ]; then printf '%s' "$1"; return 0; fi
250
258
  local devroot="${TRANTOR_DEV_ROOT:-$HOME/development}"
251
- if [ -d "$devroot/$2" ]; then
252
- echo "— opening $2 in its checkout: $devroot/$2 —" >&2
253
- printf '%s' "$devroot/$2"; return 0
259
+ if [ -d "$devroot/$target" ]; then
260
+ echo "— opening $target in its checkout: $devroot/$target —" >&2
261
+ printf '%s' "$devroot/$target"; return 0
254
262
  fi
255
- echo "trantor open: '$2' has no checkout at $devroot/$2 and this is '$herebase' — cd into the project first" >&2
263
+ echo "trantor open: '$target' has no checkout at $devroot/$target — cd into the project first" >&2
256
264
  return 1
257
265
  }
258
266
 
package/bin/doctor.mjs CHANGED
@@ -189,6 +189,24 @@ for (const c of CLIS) {
189
189
  }
190
190
  if (!installed) warn("no crew CLIs found", "install at least one of: codex, gemini, kimi, opencode — Trantor orchestrates whatever you have");
191
191
 
192
+ // ── providers: READY, not merely installed (#6390) ──────────────────────────────────────────
193
+ // The crew-CLI rows above say installed/wired. lib/providers.mjs says READY: one live usage call
194
+ // per provider (the SAME probes lib/balances.mjs serves the bar — no second detector), each
195
+ // answering with a state + reason, so "authenticated" never quietly means "expired" or "dry".
196
+ section("providers");
197
+ {
198
+ const { providerStatus } = await import("../lib/providers.mjs");
199
+ let rows = [];
200
+ try { rows = await providerStatus(); }
201
+ catch (e) { note(`provider registry could not run (${e?.message || e})`); }
202
+ for (const r of rows) {
203
+ const line = `${r.provider}: ${r.state} — ${r.reason}`;
204
+ if (r.state === "connected") ok(line);
205
+ else if (r.state === "unknown") note(line);
206
+ else warn(line, r.actions.includes("paste-key") ? `probe the key first: trantor provider verify ${r.provider} --key sk-…` : null);
207
+ }
208
+ }
209
+
192
210
  // ---- key attribution: WHICH key does each surface actually spend on? ------------------------
193
211
  // Provider keys resolve through a LAYERED lookup and nothing ever showed which layer won. On
194
212
  // 2026-08-25 a $14 DeepSeek day could not be explained: ~/.token-scrooge/.env held the only
@@ -226,6 +244,9 @@ const crewUsesVar = (v) => {
226
244
  const provider = SEAT_ENV_VARS[v];
227
245
  if (!provider) return false;
228
246
  const configured = OPENCODE_CFG?.provider?.[provider]?.options?.apiKey;
247
+ // SAFETY: configured is the opencode.json apiKey field decoded by JSON.parse; the check
248
+ // separates "a {env:VAR} template string" from any other shape (literal key / absent).
249
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
229
250
  if (typeof configured === "string" && configured.includes("{env:")) return true; // resolves from env at run time
230
251
  return !configured; // a LITERAL key bypasses the env layer
231
252
  };
package/bin/provider.mjs CHANGED
@@ -2,10 +2,13 @@
2
2
  // trantor provider — bring ANY model to the crew (BYOM). opencode is a universal adapter, so a
3
3
  // provider you configure there (or declare here) becomes a crew seat with no code change.
4
4
  //
5
- // trantor provider # list seats: built-in + discovered, with availability + tier
5
+ // trantor provider # seats (built-in + discovered) + the provider status board
6
+ // trantor provider status [--json] # the registry: state + reason per provider (#6390)
7
+ // trantor provider login <name> # run the CLI's own login command (the pane's "login" action)
8
+ // trantor provider verify <name> --key sk-… [--json] # probe a CANDIDATE key, write nothing
6
9
  // trantor provider add <name> [--key sk-…] [--plan api|coding-plan|max] [--label <bus-name>]
7
10
  // [--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)
11
+ // trantor provider remove <name> [--credentials] # drop it from your profile (--credentials: also the key)
9
12
  //
10
13
  // `add` writes <NAME>_API_KEY to ~/.agent-bus/.env (if --key given), declares the plan in your
11
14
  // quota profile, verifies opencode can see the provider's models, and prints the seat spec. For a
@@ -15,9 +18,10 @@
15
18
  import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, appendFileSync } from "node:fs";
16
19
  import { join, dirname } from "node:path";
17
20
  import { homedir } from "node:os";
18
- import { execSync } from "node:child_process";
21
+ import { execSync, spawnSync } from "node:child_process";
19
22
  import { pathToFileURL } from "node:url";
20
23
  import { buildRoster, loadWorld } from "./advise.mjs";
24
+ import { providerStatus, providerVerify, PROVIDERS } from "../lib/providers.mjs";
21
25
 
22
26
  const H = homedir();
23
27
  const ENV = join(H, ".agent-bus", ".env");
@@ -69,6 +73,55 @@ function listSeats() {
69
73
  console.log(`${C.dim}browse models:${C.off} trantor models [<provider>]`);
70
74
  }
71
75
 
76
+ // The status board (#6390) — the human face of lib/providers.mjs. Same rows the --json contract
77
+ // and the desktop pane render; every row carries a state AND a reason, so nothing can show blank.
78
+ const STATE_MARK = {
79
+ connected: (c) => `${C.grn}●${c.off}`,
80
+ over_quota: (c) => `${C.red}✗${c.off}`,
81
+ expired: (c) => `${C.red}✗${c.off}`,
82
+ not_installed: (c) => `${C.dim}○${c.off}`,
83
+ not_logged_in: (c) => `${C.dim}○${c.off}`,
84
+ unknown: (c) => `${C.yel}?${c.off}`,
85
+ };
86
+
87
+ function printStatusRows(rows) {
88
+ console.log("PROVIDERS — state + reason per provider (detail: trantor provider status --json)\n");
89
+ for (const r of rows) {
90
+ const mark = (STATE_MARK[r.state] || STATE_MARK.unknown)(C);
91
+ console.log(` ${mark} ${r.provider.padEnd(12)} ${r.state.padEnd(14)} ${C.dim}${r.reason}${C.off}`);
92
+ }
93
+ }
94
+
95
+ async function statusCmd(opts) {
96
+ const rows = await providerStatus();
97
+ if (opts.json) { console.log(JSON.stringify(rows, null, 2)); return; }
98
+ printStatusRows(rows);
99
+ const fixable = rows.filter((r) => r.actions.includes("login") || r.actions.includes("paste-key"));
100
+ if (fixable.length) {
101
+ console.log(`\n${C.dim}fix one:${C.off} ${fixable.map((r) => r.provider).join(", ")}`);
102
+ console.log(`${C.dim}probe a key before saving it:${C.off} trantor provider verify <name> --key sk-…`);
103
+ }
104
+ }
105
+
106
+ // The pre-save verify seam (#6391's ask): probe a CANDIDATE key through the SAME registry probe
107
+ // the status board uses, and write nothing anywhere — .env, profile.json and opencode.json are
108
+ // only touched later, by `provider add`, once the key is known live.
109
+ async function verifyCmd(name, opts) {
110
+ if (!name || name.startsWith("--") || name === "help" || !opts.key) {
111
+ console.error("usage: trantor provider verify <name> --key sk-… [--json]");
112
+ process.exit(1);
113
+ }
114
+ let r;
115
+ try { r = await providerVerify(name, opts.key); }
116
+ catch (e) { console.error(String(e?.message || e)); process.exit(1); }
117
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
118
+ const mark = (STATE_MARK[r.state] || STATE_MARK.unknown)(C);
119
+ console.log(` ${mark} ${r.provider.padEnd(12)} ${r.state.padEnd(14)} ${C.dim}${r.reason}${C.off}`);
120
+ console.log(r.state === "connected"
121
+ ? `\n${C.grn}Key is live.${C.off} Nothing was written — commit it: trantor provider add ${r.provider} --key …`
122
+ : `\n${C.dim}Nothing was written.${C.off} Fix the state above, then: trantor provider add ${r.provider} --key …`);
123
+ }
124
+
72
125
  function addProvider(name, opts) {
73
126
  // A flag left in the name position (`provider add --help`) is a usage question, not a provider
74
127
  // name (#5998): the old path minted a '--help' provider into profile.json and a __HELP_API_KEY
@@ -123,10 +176,10 @@ function addProvider(name, opts) {
123
176
  console.log(`${C.dim}For difficulty-aware routing across its catalog, score it once (weekly):${C.off} scrooge-capabilities`);
124
177
  }
125
178
 
126
- function removeProvider(name) {
179
+ function removeProvider(name, opts = {}) {
127
180
  // Same guard as add (#5998): a flag-like name is a usage question, not a provider.
128
181
  if (!name || name.startsWith("--") || name === "help") {
129
- console.error("usage: trantor provider remove <name>");
182
+ console.error("usage: trantor provider remove <name> [--credentials]");
130
183
  process.exit(1);
131
184
  }
132
185
  const FILE = join(H, ".agent-bus", "profile.json");
@@ -138,6 +191,49 @@ function removeProvider(name) {
138
191
  } else {
139
192
  console.log(`'${name}' is not in your profile.`);
140
193
  }
194
+ // The Accounts pane's "remove" affordance (#6391) passes --credentials: drop the key line too,
195
+ // so removing a provider from the UI doesn't leave a live secret in the crew's .env.
196
+ if (opts.credentials) {
197
+ const k = envKeyName(name);
198
+ if (existsSync(ENV)) {
199
+ const cur = readFileSync(ENV, "utf8");
200
+ const next = cur.split("\n").filter((l) => !l.startsWith(`${k}=`)).join("\n");
201
+ if (next !== cur) {
202
+ writeFileSync(ENV, next);
203
+ try { chmodSync(ENV, 0o600); } catch {}
204
+ console.log(`${C.grn}✓${C.off} removed ${k} from ~/.agent-bus/.env`);
205
+ } else {
206
+ console.log(`${C.dim}${k} was not in ~/.agent-bus/.env${C.off}`);
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ // `provider login <name>` — the pane's "login" action (#6391): run the CLI's OWN login command
213
+ // in the foreground (stdio inherited — OAuth flows print QR codes/URLs and need real stdin),
214
+ // then point at the status board for the live re-check. api-key providers have no login to run;
215
+ // the hint is the paste-key path.
216
+ function loginProvider(name) {
217
+ if (!name || name.startsWith("--") || name === "help") {
218
+ console.error("usage: trantor provider login <name>");
219
+ process.exit(1);
220
+ }
221
+ const p = PROVIDERS.find((x) => x.provider === name.toLowerCase());
222
+ if (!p) { console.error(`unknown provider '${name}' — one of: ${PROVIDERS.map((x) => x.provider).join(", ")}`); process.exit(1); }
223
+ if (!p.loginRun) {
224
+ console.log(`${p.label} authenticates by API key — no login flow to run.`);
225
+ console.log(`${C.dim}probe a candidate key first, then commit it:${C.off}`);
226
+ console.log(` trantor provider verify ${p.provider} --key sk-…`);
227
+ console.log(` trantor provider add ${p.provider} --key sk-…`);
228
+ return;
229
+ }
230
+ console.log(`${C.dim}running:${C.off} ${p.loginRun.join(" ")} ${C.dim}(the CLI's own login — sign in there)${C.off}`);
231
+ const r = spawnSync(p.loginRun[0], p.loginRun.slice(1), { stdio: "inherit" });
232
+ if (r.error || (r.status !== 0 && r.status !== null)) {
233
+ console.error(`\n${p.loginRun[0]} exited ${r.status ?? "?"} — install it first, then re-run: trantor provider login ${p.provider}`);
234
+ process.exit(1);
235
+ }
236
+ console.log(`\n${C.dim}re-check it live:${C.off} trantor provider status`);
141
237
  }
142
238
 
143
239
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -149,10 +245,22 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
149
245
  else if (rest[i] === "--label") opts.label = rest[++i];
150
246
  else if (rest[i] === "--base-url" || rest[i] === "--baseurl") opts.baseUrl = rest[++i];
151
247
  else if (rest[i] === "--models") opts.models = rest[++i];
248
+ else if (rest[i] === "--json") opts.json = true;
249
+ else if (rest[i] === "--credentials") opts.credentials = true;
152
250
  else pos.push(rest[i]);
153
251
  }
154
- if (!sub || sub === "list") listSeats();
252
+ // The default list leads with the seats roster and closes with the provider status board —
253
+ // one command answers both "what can I launch" and "what is actually alive" (#6390).
254
+ const defaultList = async () => {
255
+ listSeats();
256
+ console.log("");
257
+ printStatusRows(await providerStatus());
258
+ };
259
+ if (!sub || sub === "list") await defaultList();
260
+ else if (sub === "status") await statusCmd(opts);
261
+ else if (sub === "verify") await verifyCmd(pos[0], opts);
262
+ else if (sub === "login") loginProvider(pos[0]);
155
263
  else if (sub === "add") addProvider(pos[0], opts);
156
- else if (sub === "remove" || sub === "rm") removeProvider(pos[0]);
157
- else { console.error(`unknown subcommand '${sub}' — use: list | add | remove`); process.exit(1); }
264
+ else if (sub === "remove" || sub === "rm") removeProvider(pos[0], opts);
265
+ else { console.error(`unknown subcommand '${sub}' — use: list | status | verify | login | add | remove`); process.exit(1); }
158
266
  }
@@ -36,7 +36,9 @@ function readStdin() {
36
36
  }
37
37
 
38
38
  function fileOf(toolName, input) {
39
- if (!input || typeof input !== "object") return "";
39
+ // Only the tool input's own path fields are read; a truthy non-object input has neither, and
40
+ // String(undefined || "") is "" exactly like the old object guard returned.
41
+ if (!input) return "";
40
42
  if (toolName === "NotebookEdit") return String(input.notebook_path || "");
41
43
  return String(input.file_path || "");
42
44
  }
package/hooks/lib/api.mjs CHANGED
@@ -63,7 +63,9 @@ function projectFromQuery(pathOrUrl) {
63
63
  }
64
64
  function projectOf(explicit, payload, pathOrUrl) {
65
65
  if (explicit) return explicit;
66
- if (payload && typeof payload === "object" && payload.project) return String(payload.project);
66
+ // Only the payload's own project field is read; a truthy non-object payload has no such field
67
+ // and falls through to the query string exactly as the old object guard made it do.
68
+ if (payload && payload.project) return String(payload.project);
67
69
  return projectFromQuery(pathOrUrl);
68
70
  }
69
71
  // The signing identity for a request about `project`. An explicit RELAY_SESSION/RELAY_AGENT still
@@ -16,7 +16,9 @@ const CAP_MS = 4000;
16
16
 
17
17
  function thresholds() {
18
18
  let t = DEFAULT_LOW, q = DEFAULT_LOW_QUOTA_PCT;
19
- try { const c = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")); if (c.lowBalance) t = { ...DEFAULT_LOW, ...c.lowBalance }; if (typeof c.lowQuotaPct === "number") q = c.lowQuotaPct; } catch {}
19
+ // JSON.parse is the boundary: config.json can hold any JSON number but never NaN/Infinity,
20
+ // so Number.isFinite accepts exactly the numeric lowQuotaPct values the old typeof did.
21
+ try { const c = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")); if (c.lowBalance) t = { ...DEFAULT_LOW, ...c.lowBalance }; if (Number.isFinite(c.lowQuotaPct)) q = c.lowQuotaPct; } catch {}
20
22
  return { t, q };
21
23
  }
22
24
 
@@ -203,6 +203,10 @@ function collectTurns(transcriptPath) {
203
203
  if (!(r.type === "user" || r.type === "assistant") || !r.message) continue;
204
204
  const c = r.message.content;
205
205
  let text = "";
206
+ // SAFETY: this IS the transcript I/O boundary — the jsonl row was JSON.parse'd above and
207
+ // Claude message content is documented as a string or an array of typed blocks; the two
208
+ // branches decode exactly those shapes, anything else stays "".
209
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
206
210
  if (typeof c === "string") text = c;
207
211
  else if (Array.isArray(c)) text = c.filter(b => b?.type === "text").map(b => b.text).join("\n");
208
212
  text = (text || "").trim();
@@ -15,5 +15,7 @@ export function isSubagentTranscript(p) {
15
15
  export const SUSPECT_CACHE_READ = 50e6;
16
16
  export const SUSPECT_USD = 50;
17
17
  export function isImplausibleCost({ usd = null, cacheRead = 0 } = {}) {
18
- return (cacheRead || 0) > SUSPECT_CACHE_READ || (typeof usd === "number" && usd > SUSPECT_USD);
18
+ // usd's contract is number|null (computed token cost); the null check keeps the absent case
19
+ // from comparing, and non-numbers outside that contract never fire the suspicion either way.
20
+ return (cacheRead || 0) > SUSPECT_CACHE_READ || (usd !== null && usd > SUSPECT_USD);
19
21
  }
@@ -37,7 +37,10 @@ try {
37
37
  if (!ctx.project) silent();
38
38
 
39
39
  const r = await signedGet(`${relayUrl(ctx.project)}/overseer/context?project=${encodeURIComponent(ctx.project)}`, { session: ctx.session });
40
- if (!r.ok || !r.json || typeof r.json !== "object") silent();
40
+ // The response envelope is decoded by the field guards below: any truthy shape that is not the
41
+ // expected object falls out at `level < 2` (or the nothing-to-say check) and lands in silent()
42
+ // exactly like this guard used to — malformed payloads narrate nothing.
43
+ if (!r.ok || !r.json) silent();
41
44
  const c = r.json;
42
45
 
43
46
  const level = Number(c.level || 1);
@@ -19,6 +19,10 @@ async function main() {
19
19
  for await (const c of process.stdin) raw += c;
20
20
  const j = JSON.parse(raw);
21
21
  const rl = j.rate_limits;
22
+ // SAFETY: rate_limits is the Claude statusline envelope decoded by JSON.parse above; the check
23
+ // separates "tick carries a rate_limits envelope" (any object, even field-less — it refreshes
24
+ // the stamp below) from "no envelope" (primitives/null — a free exit).
25
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
22
26
  if (!rl || typeof rl !== "object") return; // most ticks carry none — free exit
23
27
  const sid = String(j.session_id || j.sessionId || "nosession").replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 80);
24
28
  const dir = join(homedir(), ".agent-bus");
@@ -58,6 +58,10 @@ function usageRows(file) {
58
58
  let r; try { r = JSON.parse(line); } catch { continue; }
59
59
  if (!firstUserText && r.type === "user" && r.message) {
60
60
  const c = r.message.content;
61
+ // SAFETY: this IS the transcript I/O boundary — the Claude messages.jsonl line was
62
+ // JSON.parse'd above, and message content is documented as a string or an array of
63
+ // typed blocks; the two branches decode exactly those shapes, anything else is "".
64
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
61
65
  firstUserText = (typeof c === "string" ? c : Array.isArray(c) ? c.filter(b => b?.type === "text").map(b => b.text).join(" ") : "").trim();
62
66
  }
63
67
  const u = r?.message?.usage;
package/hub.mjs CHANGED
@@ -32,9 +32,9 @@ const PEER_TTL_MS = Math.max(Number.isFinite(_peerTtlRaw) ? _peerTtlRaw : PEER_T
32
32
  // git post-commit, SubagentStart/Stop, focus hook); the instant a channel breaks — a crew seat torn down
33
33
  // mid-flight, a fork that crashed, a session that died uncleanly — the card is orphaned in whatever lane it
34
34
  // was in and NOTHING ever swept it (prunePeers only ever closed the ONE focus card, and only after 6h).
35
- // The reaper is the general safety net: a doing/testing card whose OWNER is OFFLINE past this grace window
36
- // moves to "stale" (a distinct terminal lane you triage by hand). Only fires on an OFFLINE owner, so a live
37
- // long-running task is never touched the owner-alive-but-idle case is handled by the manual /sweep path.
35
+ // The reaper is the general safety net: a doing card whose OWNER is OFFLINE past this grace window moves to
36
+ // "stale" (a distinct terminal lane you triage by hand). Testing is the operator's queue, so owner presence
37
+ // is irrelevant there and the reaper never touches it. The owner-alive-but-idle case stays with manual /sweep.
38
38
  const REAP_GRACE_MS = Number(process.env.RELAY_REAP_GRACE_MS || 15 * 60 * 1000); // 15m offline + untouched
39
39
  // Supersession lapse. The baton claim was documented "never unset", which is right while the claimant
40
40
  // lives and wrong the moment it dies: a session that consumed a handoff, went quiet and was killed left
@@ -778,9 +778,22 @@ function cardOwnerOnline(t, cutoff) {
778
778
  }
779
779
  return false;
780
780
  }
781
+ function cardOwnerLastSeen(t) {
782
+ let latest = 0;
783
+ for (const k of new Set([t.assignee, t.parent, t.by].filter(Boolean))) {
784
+ latest = Math.max(latest, state.peers[k]?.lastSeen || 0);
785
+ }
786
+ return latest;
787
+ }
788
+ function appendReaperStaleLog(t, reason, ts) {
789
+ const seen = cardOwnerLastSeen(t);
790
+ const lastSeen = seen ? `${humanMs(ts - seen)} ago` : "never";
791
+ appendTaskLog(t, "reaper", `${reason}; owner last seen ${lastSeen}`, ts);
792
+ }
781
793
  // The general stale-card reaper prunePeers never was. Every 60s:
782
794
  // (a) close a focus card once its session has been OFFLINE past FOCUS_OFFLINE_MS (not the old 6h peer TTL).
783
- // (b) move an OFFLINE-owner doing/testing card to "stale" once it's untouched past REAP_GRACE_MS.
795
+ // (b) move an OFFLINE-owner doing card to "stale" once it's untouched past REAP_GRACE_MS.
796
+ // Testing is waiting for the operator's verdict and is therefore outside the reaper's authority.
784
797
  // It NEVER touches a card whose owner is still online, so a live long task is safe; the owner-alive-but-idle
785
798
  // "forgot its card" case is left to the explicit /sweep path (preview + confirm).
786
799
  function reapStaleCards() {
@@ -791,15 +804,17 @@ function reapStaleCards() {
791
804
  let changed = false;
792
805
  for (const t of state.tasks) {
793
806
  if (t.status === "done" || t.status === "stale") continue;
807
+ if (t.status === "testing") continue;
794
808
  if (t.status === "todo" && (t.updated || t.ts || 0) < now() - TODO_STALE_MS) {
795
809
  const from = t.status;
796
810
  const untouchedAt = t.updated || t.ts || 0;
797
811
  const agedDays = Math.floor((now() - untouchedAt) / 86400000);
798
- (t.history ||= []).push({ from, to: "stale", by: "reaper", ts: now() });
812
+ const reapedAt = now();
813
+ (t.history ||= []).push({ from, to: "stale", by: "reaper", ts: reapedAt });
799
814
  if (t.history.length > 60) t.history.splice(0, 20);
800
- appendTaskLog(t, "reaper", `todo aged out after ${agedDays}d untouched`);
815
+ appendReaperStaleLog(t, `todo aged out after ${agedDays}d untouched`, reapedAt);
801
816
  appendCardEvent("moved", t, "reaper", from, "stale");
802
- t.status = "stale"; t.updated = now(); t._reaped = true; changed = true;
817
+ t.status = "stale"; t.updated = reapedAt; t._reaped = true; changed = true;
803
818
  continue;
804
819
  }
805
820
  if (t.source === "session") { // (a) focus cards → done when session offline
@@ -811,13 +826,15 @@ function reapStaleCards() {
811
826
  if (peerGone || longIdle) { if (closeFocusCard(t, peerGone ? t.assignee : "reaper")) changed = true; }
812
827
  continue;
813
828
  }
814
- if ((t.status === "doing" || t.status === "testing") // (b) offline-owner work cards → stale
829
+ if (t.status === "doing" // (b) offline-owner work cards → stale
815
830
  && (t.updated || t.ts || 0) < graceCut
816
831
  && !cardOwnerOnline(t, onCut)) {
817
- (t.history ||= []).push({ from: t.status, to: "stale", by: "reaper", ts: now() });
832
+ const reapedAt = now();
833
+ (t.history ||= []).push({ from: t.status, to: "stale", by: "reaper", ts: reapedAt });
818
834
  if (t.history.length > 60) t.history.splice(0, 20);
835
+ appendReaperStaleLog(t, "owner offline → stale", reapedAt);
819
836
  appendCardEvent("moved", t, "reaper", t.status, "stale");
820
- t.status = "stale"; t.updated = now(); t._reaped = true; changed = true;
837
+ t.status = "stale"; t.updated = reapedAt; t._reaped = true; changed = true;
821
838
  }
822
839
  }
823
840
  if (changed) dirty = true;
package/lib/balances.mjs CHANGED
@@ -331,7 +331,9 @@ export function fmtBalance(e) {
331
331
  return `${e.label}: ${sym}${amt} ${e.currency} left`;
332
332
  }
333
333
 
334
- function fmtReset(t) {
334
+ // exported for the provider registry (#6390): state reasons append the same reset short-form
335
+ // the balances rows print, so status and balances can never disagree about a reset date.
336
+ export function fmtReset(t) {
335
337
  const ms = Number.isFinite(t) ? t : Date.parse(t);
336
338
  if (!ms || isNaN(ms)) return "";
337
339
  const hrs = (ms - Date.now()) / 3600e3;
@@ -0,0 +1,332 @@
1
+ // trantor provider registry — ONE truth for "is this provider ready to seat?" (#6390), built the
2
+ // way Orca does onboarding (stablyai/orca: src/main/providers + src/main/claude-accounts): a
3
+ // registry per agent with a PATH detect (`command -v`), the CLI's own login command, the CLI's own
4
+ // credential artifact, and "connected" = a LIVE usage call succeeded — never "file exists".
5
+ // Consumers: `trantor provider status [--json]`, `trantor provider verify` (pre-save key check),
6
+ // the desktop provider_status/provider_verify Tauri commands (they shell the CLI — one renderer),
7
+ // and `trantor doctor`. The JSON row shape is FROZEN (the Accounts pane #6391 and wizard #6392
8
+ // build against it):
9
+ // [{ provider, label, kind, connect, binary:{name,installed,path}, auth:{artifact,present,mode},
10
+ // state, reason, usage:{...balances row}, actions:["login"|"paste-key"|"recheck"|"remove"] }]
11
+ // state ∈ connected | not_installed | not_logged_in | expired | over_quota | unknown — ALWAYS with
12
+ // a non-empty reason. The kimi row that used to render a blank "plan" in the bar is the bug this
13
+ // fixes: no row exists without a state and a reason.
14
+ //
15
+ // PROBES ARE NOT DUPLICATED: every live check is lib/balances.mjs — the ADAPTERS (kimi/zai/qwen/
16
+ // deepseek/openrouter/moonshot/claude) plus fetchBalances' codex block. This module only decides
17
+ // WHAT to ask and maps the answer to a state.
18
+ import { execFileSync } from "node:child_process";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { isAbsolute, join } from "node:path";
21
+ import { homedir } from "node:os";
22
+ import { fetchBalances, fmtBalance, fmtReset } from "./balances.mjs";
23
+ import { resolveKeys } from "./provider-keys.mjs";
24
+
25
+ export const STATES = ["connected", "not_installed", "not_logged_in", "expired", "over_quota", "unknown"];
26
+ export const ACTIONS = ["login", "paste-key", "recheck", "remove"];
27
+
28
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
29
+ const envKeyName = (p) => `${String(p).toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
30
+
31
+ // Orca's detectCmd: a PATH probe, aliases included, first hit wins. Injectable PATH keeps the
32
+ // drills hermetic (opts.path) — a fake bin dir can make `kimi` exist without the real CLI.
33
+ // The shell itself is /bin/sh ABSOLUTE: a relative "sh" would be resolved through the very PATH
34
+ // we are overriding, and a drill PATH without /bin would read as "no CLIs installed".
35
+ function detectBinary(names, opts = {}) {
36
+ const path = opts.path || process.env.PATH;
37
+ for (const n of names.filter(Boolean)) {
38
+ try {
39
+ const p = execFileSync("/bin/sh", ["-c", `command -v ${n}`], { encoding: "utf8", env: { ...process.env, PATH: path } }).trim();
40
+ if (p) return { name: n, installed: true, path: p };
41
+ } catch { /* not on PATH — try the next alias */ }
42
+ }
43
+ return { name: names[0] || null, installed: false, path: null };
44
+ }
45
+
46
+ // The opencode.json provider block key, decoded once at this boundary: a LITERAL key comes back
47
+ // as `literal` (feedable to a probe), the "{env:VAR}" template comes back as `template` (the seat
48
+ // resolves it at run time, so only the env decides presence here).
49
+ function opencodeKey(home, provider) {
50
+ const cfg = readJson(join(home, ".config", "opencode", "opencode.json"));
51
+ const v = cfg?.provider?.[provider]?.options?.apiKey;
52
+ // SAFETY: v is the opencode.json apiKey field decoded by JSON.parse above; the check separates
53
+ // "a string key" (literal, or the {env:VAR} template) from "any other shape" (ignored — a
54
+ // non-string is never fed to a probe).
55
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
56
+ if (!v || typeof v !== "string") return { literal: null, template: null };
57
+ const m = /^\{env:([A-Z0-9_]+)\}$/.exec(v);
58
+ return m ? { literal: null, template: m[1] } : { literal: v, template: null };
59
+ }
60
+
61
+ // Claude Code keeps its OAuth token in ~/.claude/.credentials.json (or $CLAUDE_CONFIG_DIR),
62
+ // then the macOS keychain item "Claude Code-credentials". Attribute-only keychain lookup (no -w,
63
+ // doctor's rule): a health read must never raise a GUI secret prompt. The keychain is tried ONLY
64
+ // when the caller did not inject a home (ctx.allowKeychain) — drills stay hermetic and never
65
+ // touch the operator's real secrets.
66
+ function claudeAuth(home, env, ctx = {}) {
67
+ const dirs = [];
68
+ const cfgDir = env.CLAUDE_CONFIG_DIR;
69
+ if (cfgDir) dirs.push(isAbsolute(cfgDir) ? cfgDir : join(home, cfgDir));
70
+ dirs.push(join(home, ".claude"));
71
+ for (const dir of dirs) {
72
+ const j = readJson(join(dir, ".credentials.json"));
73
+ const tok = j?.claudeAiOauth;
74
+ if (tok?.accessToken) {
75
+ const exp = Date.parse(tok.expiresAt);
76
+ return { artifact: `${dir.replace(home, "~")}/.credentials.json`, present: true, mode: "oauth", expiresAt: Number.isFinite(exp) ? exp : null };
77
+ }
78
+ }
79
+ // The keychain is skipped when the caller injected a home (drill) or set TRANTOR_NO_KEYCHAIN=1
80
+ // (CLI drill: fake $HOME isolates the FILES, but the login keychain is machine-wide — a drill
81
+ // must never read, or even attribute-probe, the operator's real secrets).
82
+ if (ctx.allowKeychain && ctx.env.TRANTOR_NO_KEYCHAIN !== "1" && process.platform === "darwin") {
83
+ try {
84
+ execFileSync("/usr/bin/security", ["find-generic-password", "-s", "Claude Code-credentials"], { stdio: "ignore", timeout: 4000 });
85
+ return { artifact: "keychain: Claude Code-credentials", present: true, mode: "oauth", expiresAt: null };
86
+ } catch { /* no keychain item → fall through to absent */ }
87
+ }
88
+ return { artifact: "~/.claude/.credentials.json", present: false, mode: null, expiresAt: null };
89
+ }
90
+
91
+ // Codex auth.json modes (Orca): tokens present → "chatgpt" (OAuth); OPENAI_API_KEY → "apikey".
92
+ // The id_token's `email` claim names the account — reason text only, tokens never leave here.
93
+ function codexAuth(home) {
94
+ const j = readJson(join(home, ".codex", "auth.json"));
95
+ if (j?.tokens?.access_token) {
96
+ let email = null;
97
+ try {
98
+ const payload = JSON.parse(Buffer.from(String(j.tokens.id_token || "").split(".")[1] || "", "base64url").toString("utf8"));
99
+ email = payload?.email || null;
100
+ } catch { /* a malformed id_token costs the reason its email, never the row */ }
101
+ return { artifact: "~/.codex/auth.json", present: true, mode: "chatgpt", email };
102
+ }
103
+ if (j?.OPENAI_API_KEY) return { artifact: "~/.codex/auth.json", present: true, mode: "apikey", email: null };
104
+ return { artifact: "~/.codex/auth.json", present: false, mode: null, email: null };
105
+ }
106
+
107
+ // api-key providers: env first (the layered resolveKeys the crew itself uses), then a LITERAL key
108
+ // wired into opencode.json. A "{env:VAR}" template defers to the env by design.
109
+ function apiKeyAuth(home, env, { envKeys, ocProvider, artifact }) {
110
+ for (const k of envKeys) {
111
+ if (env[k]) return { artifact: `env ${k}`, present: true, mode: "api-key", key: env[k], expiresAt: null };
112
+ }
113
+ if (ocProvider) {
114
+ const oc = opencodeKey(home, ocProvider);
115
+ if (oc.literal) return { artifact: "~/.config/opencode/opencode.json", present: true, mode: "api-key", key: oc.literal, expiresAt: null };
116
+ if (oc.template && env[oc.template]) return { artifact: `env ${oc.template}`, present: true, mode: "api-key", key: env[oc.template], expiresAt: null };
117
+ }
118
+ return { artifact, present: false, mode: null, key: null, expiresAt: null };
119
+ }
120
+
121
+ const fileAuth = (home, file, mode = "oauth") => {
122
+ const rel = file.replace(home, "~");
123
+ return existsSync(file)
124
+ ? { artifact: rel, present: true, mode, key: null, expiresAt: null }
125
+ : { artifact: rel, present: false, mode: null, key: null, expiresAt: null };
126
+ };
127
+
128
+ // PROBE_VIA: providers whose live check rides ANOTHER adapter — dsh bills through DeepSeek's API,
129
+ // so its probe IS the deepseek adapter (rebranded on the way out). agy has no balances adapter.
130
+ const PROBE_VIA = { dsh: "deepseek" };
131
+
132
+ export const PROVIDERS = [
133
+ { provider: "claude", label: "Claude", kind: "windows", connect: "cli-login",
134
+ binary: ["claude"], loginCmd: "claude", loginRun: ["claude"], envKeys: [], probe: "balances",
135
+ auth: claudeAuth,
136
+ hint: "sign in with your Anthropic account on first run" },
137
+ { provider: "codex", label: "Codex", kind: "windows", connect: "cli-login",
138
+ binary: ["codex"], loginCmd: "codex login", loginRun: ["codex", "login"], envKeys: [], probe: "balances",
139
+ auth: (home) => codexAuth(home),
140
+ hint: "sign in with your ChatGPT account" },
141
+ { provider: "kimi", label: "Kimi Code", kind: "quota", connect: "cli-login",
142
+ binary: ["kimi"], loginCmd: "kimi (then /login)", loginRun: ["kimi", "login"], envKeys: ["KIMI_API_KEY"], probe: "balances",
143
+ auth: (home, env) => (env.KIMI_API_KEY
144
+ ? { artifact: "env KIMI_API_KEY", present: true, mode: "api-key", key: env.KIMI_API_KEY, expiresAt: null }
145
+ : fileAuth(home, join(home, ".kimi", "credentials"))),
146
+ hint: "Kimi account or Moonshot API key" },
147
+ { provider: "zai", label: "Z.ai (GLM)", kind: "quota", connect: "api-key",
148
+ binary: ["opencode"], loginCmd: "trantor provider add zai --key …", loginRun: null, envKeys: ["ZAI_API_KEY", "GLM_API_KEY"], probe: "balances",
149
+ auth: (home, env) => apiKeyAuth(home, env, { envKeys: ["ZAI_API_KEY", "GLM_API_KEY"], ocProvider: "zai-coding-plan", artifact: "env ZAI_API_KEY" }),
150
+ hint: "get a coding-plan key at z.ai, then trantor provider add zai --key …" },
151
+ { provider: "qwen", label: "Qwen", kind: "quota", connect: "cli-login",
152
+ binary: ["qwen"], loginCmd: "qwen", loginRun: ["qwen"], envKeys: ["QWEN_API_KEY"], probe: "balances",
153
+ auth: (home, env) => (env.QWEN_API_KEY
154
+ ? { artifact: "env QWEN_API_KEY", present: true, mode: "api-key", key: env.QWEN_API_KEY, expiresAt: null }
155
+ : fileAuth(home, join(home, ".qwen", "oauth_creds.json"))),
156
+ hint: "run qwen once — its OAuth flow opens on first start" },
157
+ { provider: "deepseek", label: "DeepSeek", kind: "prepaid", connect: "api-key",
158
+ binary: ["opencode"], loginCmd: "trantor provider add deepseek --key …", loginRun: null, envKeys: ["DEEPSEEK_API_KEY"], probe: "balances",
159
+ auth: (home, env) => apiKeyAuth(home, env, { envKeys: ["DEEPSEEK_API_KEY"], ocProvider: "deepseek", artifact: "env DEEPSEEK_API_KEY" }),
160
+ hint: "get a key at platform.deepseek.com, then trantor provider add deepseek --key …" },
161
+ { provider: "openrouter", label: "OpenRouter", kind: "prepaid", connect: "api-key",
162
+ binary: ["opencode"], loginCmd: "trantor provider add openrouter --key …", loginRun: null, envKeys: ["OPENROUTER_API_KEY"], probe: "balances",
163
+ auth: (home, env) => apiKeyAuth(home, env, { envKeys: ["OPENROUTER_API_KEY"], ocProvider: "openrouter", artifact: "env OPENROUTER_API_KEY" }),
164
+ hint: "get a key at openrouter.ai/keys — one key fronts hundreds of models" },
165
+ { provider: "moonshot", label: "Moonshot", kind: "prepaid", connect: "api-key",
166
+ binary: [], loginCmd: "trantor provider add moonshot --key …", loginRun: null, envKeys: ["MOONSHOT_API_KEY"], probe: "balances",
167
+ auth: (home, env) => apiKeyAuth(home, env, { envKeys: ["MOONSHOT_API_KEY"], ocProvider: null, artifact: "env MOONSHOT_API_KEY" }),
168
+ hint: "Moonshot platform key (api.moonshot.ai) — distinct from the Kimi Code login" },
169
+ { provider: "agy", label: "Antigravity (agy)", kind: "unknown", connect: "cli-login",
170
+ binary: ["agy"], loginCmd: "agy", loginRun: ["agy"], envKeys: [], probe: null,
171
+ auth: () => ({ artifact: null, present: false, mode: null, key: null, expiresAt: null }),
172
+ hint: "Antigravity CLI — Google sign-in on first run" },
173
+ { provider: "dsh", label: "DeepSeek Harness", kind: "prepaid", connect: "api-key",
174
+ binary: ["dsh"], loginCmd: "npm i -g @deepseek-ai/dsh && trantor connect", loginRun: null, envKeys: ["DEEPSEEK_API_KEY"], probe: "balances",
175
+ auth: (home, env) => apiKeyAuth(home, env, { envKeys: ["DEEPSEEK_API_KEY"], ocProvider: null, artifact: "env DEEPSEEK_API_KEY" }),
176
+ hint: "API-billed via DEEPSEEK_API_KEY — the same key the deepseek seat uses" },
177
+ ];
178
+
179
+ const REG = Object.fromEntries(PROVIDERS.map((p) => [p.provider, p]));
180
+
181
+ // The probe, for real: lib/balances.mjs IS the probe surface (card rule — reuse, never duplicate).
182
+ // `only: [via]` hits exactly one adapter; dsh's row is rebranded on the way out so usage stays a
183
+ // true balances row under the dsh provider name. `fetch` is the drill seam — tests hand a stub
184
+ // instead of the real fetchBalances, no module mocking involved.
185
+ async function balancesProbe(name, env, fetch = fetchBalances) {
186
+ const via = PROBE_VIA[name] || name;
187
+ const rows = await fetch(env, { only: [via] });
188
+ const row = (rows || []).find((r) => r.provider === via);
189
+ if (!row) return null;
190
+ return via === name ? row : { ...row, provider: name, label: REG[name].label };
191
+ }
192
+ // Named export for drills: the rebrand/via mapping is part of the probe contract (dsh rides the
193
+ // deepseek adapter), and the drill asserts it with a stubbed `fetch`, never the real network.
194
+ export { balancesProbe as balancesProbeForDrills };
195
+
196
+ // Reason from a healthy balances row: the detail line when the adapter wrote one (qwen's
197
+ // console-only note, kimi's window line), else fmtBalance's text minus the "Label: " prefix.
198
+ // This is the kimi fix: `remainingPct: null` no longer blanks the row — the detail IS the reason.
199
+ function reasonFromUsage(row) {
200
+ if (row.detail) return row.detail;
201
+ const s = fmtBalance(row);
202
+ const at = s.indexOf(": ");
203
+ return at > -1 ? s.slice(at + 2) : s;
204
+ }
205
+
206
+ const shortError = (e) => String(e ?? "unknown error").slice(0, 160);
207
+
208
+ // Probe outcome → (state, reason). Orca's rule: a live authenticated call that ANSWERED is
209
+ // connected, whatever the numbers; the exceptions are explicit.
210
+ function stateFromProbe(p, row) {
211
+ if (!row) {
212
+ return { state: "unknown", reason: "live probe returned nothing — the provider is not configured for probing" };
213
+ }
214
+ if (!row.ok) {
215
+ const err = shortError(row.error);
216
+ if (/429|insufficient_quota|quota exceeded/i.test(err)) {
217
+ return { state: "over_quota", reason: `provider reports quota exhausted — ${err}` };
218
+ }
219
+ if (/401|403|invalid|unauthorized|rejected|expired/i.test(err)) {
220
+ return p.connect === "cli-login"
221
+ ? { state: "expired", reason: `login token rejected (${err}) — run: ${p.loginCmd}` }
222
+ : { state: "not_logged_in", reason: `API key rejected (${err}) — paste a fresh key` };
223
+ }
224
+ if (/no .*token|credentials/i.test(err)) {
225
+ return { state: "not_logged_in", reason: `no usable credential (${err}) — run: ${p.loginCmd}` };
226
+ }
227
+ return { state: "unknown", reason: `live probe failed — ${err}` };
228
+ }
229
+ // A spent quota plan ANSWERS ok with 0% left (qwen's wall: ok:true, remainingPct: 0).
230
+ if (row.kind === "quota" && row.remainingPct === 0) {
231
+ const reset = row.resetTime ? ` · resets ${fmtReset(row.resetTime)}` : "";
232
+ return { state: "over_quota", reason: `${row.detail || "quota spent"}${reset}` };
233
+ }
234
+ const who = row.plan ? `${row.plan} — ` : "";
235
+ return { state: "connected", reason: `${who}${reasonFromUsage(row)}` };
236
+ }
237
+
238
+ // actions are derived, never stored, so they can never contradict the state.
239
+ function actionsFor(p, state) {
240
+ const base = ["recheck"];
241
+ if (state !== "not_installed") base.push("remove");
242
+ if (state === "not_logged_in" || state === "expired") base.unshift(p.connect === "cli-login" ? "login" : "paste-key");
243
+ else if (state === "not_installed" && p.connect === "api-key") base.unshift("paste-key");
244
+ return ACTIONS.filter((a) => base.includes(a));
245
+ }
246
+
247
+ function row(p, { binary, auth, state, reason, usage }) {
248
+ return {
249
+ provider: p.provider, label: p.label, kind: p.kind, connect: p.connect,
250
+ binary, auth: { artifact: auth.artifact, present: !!auth.present, mode: auth.mode },
251
+ state, reason: reason || state, // never a blank reason — the kimi-bar rule, enforced at the seam
252
+ usage: usage || { ok: false, error: `not probed — ${state}` },
253
+ actions: actionsFor(p, state),
254
+ };
255
+ }
256
+
257
+ async function buildRow(p, ctx) {
258
+ const binary = detectBinary(p.binary, ctx);
259
+ const auth = p.auth(ctx.home, ctx.env, ctx);
260
+ // 1) an explicitly-expired artifact is decided WITHOUT a network round trip.
261
+ if (auth.present && Number.isFinite(auth.expiresAt) && auth.expiresAt < ctx.now) {
262
+ return row(p, { binary, auth, state: "expired",
263
+ reason: `credential expired ${new Date(auth.expiresAt).toISOString().slice(0, 10)} — run: ${p.loginCmd}` });
264
+ }
265
+ // 2) no auth AND no binary → nothing to seat and nothing to probe: not_installed. But a
266
+ // provider WITH a credential (an env key on a machine without the CLI, qwen's API key on
267
+ // an opencode-only machine) still gets its LIVE check: the account state is what the bar,
268
+ // the pane and the wizard act on, and binary.installed is its own contract field.
269
+ if (!auth.present && !binary.installed && p.binary.length) {
270
+ const what = p.connect === "cli-login" ? `${binary.name} CLI not found on PATH` : `${binary.name} not found on PATH — the seat needs it`;
271
+ return row(p, { binary, auth, state: "not_installed", reason: `${what} — install it first (run: ${p.loginCmd})` });
272
+ }
273
+ // 3) no auth at a KNOWN location → not_logged_in, naming the artifact and the fix. The probe
274
+ // would only echo this back with a slower error, so it is skipped, not trusted. But a
275
+ // provider whose artifact we cannot read (agy: no reader exists) can never claim this
276
+ // honestly — "ran once, invisible to us" would read the same as "never ran" — so with no
277
+ // artifact AND no probe the row is unknown, with the how-to-check in the reason.
278
+ if (!auth.present && auth.artifact) {
279
+ return row(p, { binary, auth, state: "not_logged_in",
280
+ reason: `no credential at ${auth.artifact} — run: ${p.loginCmd}` });
281
+ }
282
+ if (!auth.present && !p.probe) {
283
+ return row(p, { binary, auth, state: "unknown",
284
+ reason: `installed, no credential reader and no live probe — run ${p.binary[0]} once to log in, then recheck` });
285
+ }
286
+ // 4) the live check. probe === null means no balances adapter exists (agy): say unknown and
287
+ // how to check, rather than reading "binary exists" as connected (Orca's rule).
288
+ if (!p.probe) {
289
+ return row(p, { binary, auth, state: "unknown",
290
+ reason: `installed, no live probe wired — run ${p.binary[0]} once to confirm the login, then recheck` });
291
+ }
292
+ const probeEnv = auth.key && p.envKeys[0] ? { ...ctx.env, [p.envKeys[0]]: auth.key } : ctx.env;
293
+ try {
294
+ const usage = await ctx.probe(p.provider, probeEnv);
295
+ const { state, reason } = stateFromProbe(p, usage);
296
+ // Codex signs in by account, not by key — the id_token's email claim names WHO is connected
297
+ // (the operator runs several). Reason text only; the token itself never leaves the reader.
298
+ const full = state === "connected" && auth.email ? `signed in as ${auth.email} · ${reason}` : reason;
299
+ return row(p, { binary, auth, state, reason: full, usage: usage || undefined });
300
+ } catch (e) {
301
+ return row(p, { binary, auth, state: "unknown", reason: `live probe failed — ${shortError(e?.message || e)}` });
302
+ }
303
+ }
304
+
305
+ // The frozen contract. opts: { env, home, path, now, probe } — everything a drill needs to stay
306
+ // hermetic: env defaults to the LAYERED crew resolution (process.env ∪ ~/.token-scrooge/.env ∪
307
+ // ~/.agent-bus/.env), home to the real one (which also unlocks the keychain read), probe to the
308
+ // balances-backed one.
309
+ export async function providerStatus(opts = {}) {
310
+ const home = opts.home || homedir();
311
+ const ctx = {
312
+ home,
313
+ allowKeychain: !opts.home, // an injected home is a drill — never touch the operator keychain
314
+ env: opts.env ?? resolveKeys(opts.env || process.env, [join(home, ".token-scrooge", ".env"), join(home, ".agent-bus", ".env")]),
315
+ path: opts.path,
316
+ now: opts.now || Date.now(),
317
+ probe: opts.probe || balancesProbe,
318
+ };
319
+ return Promise.all(PROVIDERS.map((p) => buildRow(p, ctx)));
320
+ }
321
+
322
+ // The pre-save seam (#6391's ask): run the registry's OWN probe against a CANDIDATE key and write
323
+ // nothing anywhere. The row is the normal status row for that provider computed with the candidate
324
+ // injected — connected means the key is live BEFORE `provider add --key` commits it to .env.
325
+ export async function providerVerify(name, candidateKey, opts = {}) {
326
+ const p = REG[String(name || "").toLowerCase()];
327
+ if (!p) throw new Error(`unknown provider '${name}' — one of: ${PROVIDERS.map((x) => x.provider).join(", ")}`);
328
+ const home = opts.home || homedir();
329
+ const baseEnv = opts.env ?? resolveKeys(opts.env || process.env, [join(home, ".token-scrooge", ".env"), join(home, ".agent-bus", ".env")]);
330
+ const env = candidateKey && p.envKeys[0] ? { ...baseEnv, [p.envKeys[0]]: candidateKey } : baseEnv;
331
+ return buildRow(p, { home, allowKeychain: !opts.home, env, path: opts.path, now: opts.now || Date.now(), probe: opts.probe || balancesProbe });
332
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.40",
3
+ "version": "0.18.41",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"