trantor 0.18.39 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.mjs +4 -1
- package/bin/crew-runner.mjs +27 -6
- package/bin/crew.sh +22 -14
- package/bin/doctor.mjs +21 -0
- package/bin/provider.mjs +116 -8
- package/hooks/file-claim.mjs +3 -1
- package/hooks/lib/api.mjs +3 -1
- package/hooks/lib/balance-check.mjs +3 -1
- package/hooks/lib/handoff.mjs +4 -0
- package/hooks/lib/subagent-cost-lib.mjs +3 -1
- package/hooks/overseer-warn.mjs +4 -1
- package/hooks/statusline.mjs +4 -0
- package/hooks/subagent-cost.mjs +4 -0
- package/hub.mjs +27 -10
- package/lib/balances.mjs +3 -1
- package/lib/providers.mjs +332 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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
|
|
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)
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
761
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
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} (${
|
|
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
|
@@ -201,7 +201,7 @@ let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const r=(JSON.parse(
|
|
|
201
201
|
process.stdout.write((((r.workspace||{}).workspace_id)||"")+"\t"+(((r.root_pane||{}).pane_id)||""))}catch(e){}})'
|
|
202
202
|
}
|
|
203
203
|
_herdr_split() { # $1=pane id ("" = UI-focused pane) $2=right|down $3=cwd (optional) → new pane id
|
|
204
|
-
local a=(pane split); [ -n "$1" ] && a+=("$1"); a+=(--direction "$2" --no-focus); [ -n "$3" ] && a+=(--cwd "$3")
|
|
204
|
+
local a=(pane split); [ -n "$1" ] && a+=("$1"); a+=(--direction "$2" --no-focus); [ -n "${3:-}" ] && a+=(--cwd "$3")
|
|
205
205
|
_herdr "${a[@]}" 2>/dev/null | node -e '
|
|
206
206
|
let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const r=(JSON.parse(d.slice(d.search(/[\[{]/))).result)||{};
|
|
207
207
|
process.stdout.write(((r.pane||{}).pane_id)||"")}catch(e){}})'
|
|
@@ -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
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if [ -
|
|
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/$
|
|
252
|
-
echo "— opening $
|
|
253
|
-
printf '%s' "$devroot/$
|
|
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: '$
|
|
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 #
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|
package/hooks/file-claim.mjs
CHANGED
|
@@ -36,7 +36,9 @@ function readStdin() {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function fileOf(toolName, input) {
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/hooks/overseer-warn.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/hooks/statusline.mjs
CHANGED
|
@@ -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");
|
package/hooks/subagent-cost.mjs
CHANGED
|
@@ -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
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|