trantor 0.18.20 → 0.18.21
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/crew.sh +12 -7
- package/hooks/sessionstart.mjs +14 -2
- package/hooks/statusline.mjs +47 -0
- package/hub.mjs +23 -0
- package/lib/balances.mjs +37 -6
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.21",
|
|
4
4
|
"description": "Trantor \u2014 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/crew.sh
CHANGED
|
@@ -244,26 +244,31 @@ _orch_cmd() { # $1=dir $2=sid
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
# A recorded thread that HANDED OFF has ended: resuming it replays a dead conversation while the
|
|
247
|
-
# handoff waits for a successor (the 2026-08-27 seam — "Trantor resumes the wrong thread").
|
|
248
|
-
#
|
|
249
|
-
# FRESH id
|
|
250
|
-
# orch-sessions.txt
|
|
247
|
+
# handoff waits for a successor (the 2026-08-27 seam — "Trantor resumes the wrong thread").
|
|
248
|
+
# ANY unconsumed handoff for the project means a successor is OWED a fresh window — open must
|
|
249
|
+
# start a FRESH id so the sessionstart hook claims the baton (it then records the fresh id in
|
|
250
|
+
# orch-sessions.txt; single writer: the hook + adopt — this function writes nothing).
|
|
251
|
+
# The old predicate required the handoff to be written BY the recorded session (session_id match)
|
|
252
|
+
# — but MANUAL handoffs carry no session_id at all, so on 2026-08-31 the reboot flow resumed the
|
|
253
|
+
# same maxed-out conversation TWICE while its handoff sat unclaimed, and the injected recap
|
|
254
|
+
# banner made each resume LOOK like a clean takeover. Who wrote it doesn't matter; that it is
|
|
255
|
+
# unclaimed does.
|
|
251
256
|
_orch_takeover_sid() { # $1=project $2=recorded-sid → prints the sid open should use
|
|
252
257
|
local fresh
|
|
253
258
|
if node -e '
|
|
254
259
|
const fs=require("fs"),path=require("path"),os=require("os");
|
|
255
260
|
const dir=path.join(process.env.AGENT_BUS_DIR||process.env.RELAY_DATA_DIR||path.join(os.homedir(),".agent-bus"),"handoffs");
|
|
256
|
-
const [proj
|
|
261
|
+
const [proj]=process.argv.slice(1);
|
|
257
262
|
try{
|
|
258
263
|
const re=new RegExp("^"+proj.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"-(\\d+)\\.json$");
|
|
259
264
|
const files=fs.readdirSync(dir).map(f=>{const m=re.exec(f);return m?{f,s:Number(m[1])}:null}).filter(Boolean).sort((a,b)=>b.s-a.s);
|
|
260
265
|
for(const {f} of files){
|
|
261
266
|
const r=JSON.parse(fs.readFileSync(path.join(dir,f),"utf8"));
|
|
262
267
|
if(r.consumed) continue;
|
|
263
|
-
process.exit(
|
|
268
|
+
process.exit(0); // newest UNCONSUMED exists → a successor is owed a fresh window
|
|
264
269
|
}
|
|
265
270
|
}catch(e){}
|
|
266
|
-
process.exit(1);' "$1"
|
|
271
|
+
process.exit(1);' "$1" 2>/dev/null; then
|
|
267
272
|
fresh="$(uuidgen 2>/dev/null | tr 'A-Z' 'a-z')"
|
|
268
273
|
[ -n "$fresh" ] && { echo "— recorded session $2 handed off: starting fresh as $fresh to claim it —" >&2; printf '%s' "$fresh"; return 0; }
|
|
269
274
|
fi
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -441,6 +441,11 @@ try {
|
|
|
441
441
|
// The hold LAPSES (default 30m) so an unclaimed baton never strands every other session.
|
|
442
442
|
// Any other handoff keeps first-fresh-session-wins.
|
|
443
443
|
const isCompact = source === "compact";
|
|
444
|
+
// A RESUMED session must never claim a handoff (2026-08-31, twice in one afternoon): it
|
|
445
|
+
// already carries its whole history, so claiming injects the takeover banner into the same
|
|
446
|
+
// maxed-out context — the recap LOOKS right while the window never reset, which is worse
|
|
447
|
+
// than failing loudly. The successor is whatever fresh session `trantor open` starts.
|
|
448
|
+
const isResume = source === "resume";
|
|
444
449
|
const orchEnv = process.env.TRANTOR_ORCH || "";
|
|
445
450
|
const isOrchPane = !!orchEnv && (orchEnv === "1" || orchEnv === project); // project-matched: a child claude in another dir must not inherit the badge
|
|
446
451
|
const orchSid = readOrchSession(project);
|
|
@@ -450,12 +455,12 @@ try {
|
|
|
450
455
|
const ageMs = peek ? Date.now() - (Number(peek.stamp) || 0) * 1000 : 0;
|
|
451
456
|
const held = orchOrigin && !isOrchPane && !isCompact && ageMs < holdMs;
|
|
452
457
|
const handoff = !peek ? null
|
|
453
|
-
: (isCompact || held) ? peek
|
|
458
|
+
: (isCompact || held || isResume) ? peek
|
|
454
459
|
: loadPendingHandoff(basename(projectDir), {
|
|
455
460
|
claim: true,
|
|
456
461
|
freshSession: { session_id: stdinObj.session_id || "", transcript_path: stdinObj.transcript_path || "" },
|
|
457
462
|
});
|
|
458
|
-
const claimed = !!handoff && !isCompact && !held;
|
|
463
|
+
const claimed = !!handoff && !isCompact && !held && !isResume;
|
|
459
464
|
// Follow the thread: claiming the orchestrator's baton makes THIS session the orchestrator
|
|
460
465
|
// thread, so the map `trantor open` resumes and the app's chat reads moves with it. The pane
|
|
461
466
|
// also records itself on every fresh start — that keeps the map honest even if a future
|
|
@@ -468,6 +473,13 @@ try {
|
|
|
468
473
|
additionalContext += `<trantor-handoff-held id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}">\n`;
|
|
469
474
|
additionalContext += `🔒 A handoff from this project's ORCHESTRATOR thread is pending, and it is being HELD for the Trantor orchestrator pane (\`trantor open\` claims it on start). This session did NOT claim it and does not have its content — do not act as the successor. If the user wants THIS session to take over instead: \`trantor adopt\`, then restart this session. Unclaimed, the hold lapses in ~${mins} minute(s) and the handoff becomes first-come.\n`;
|
|
470
475
|
additionalContext += `</trantor-handoff-held>\n`;
|
|
476
|
+
} else if (handoff && isResume) {
|
|
477
|
+
// No takeover banner here — injecting it into a resumed session is exactly the failure this
|
|
478
|
+
// guard exists for: the recap reads like a clean handoff while the context never reset.
|
|
479
|
+
process.stderr.write(`[trantor] pending handoff ${handoff.id} NOT claimed — this session was RESUMED, not started fresh\n`);
|
|
480
|
+
additionalContext += `<trantor-handoff-unclaimed id="${sanitize(handoff.id)}" reason="resumed-session">\n`;
|
|
481
|
+
additionalContext += `⚠️ An unclaimed handoff (${sanitize(handoff.id)}) is waiting for this project, but THIS session was RESUMED (\`--resume\`) — it already carries its own history and is NOT the successor. Do not act on the handoff. Tell the user plainly: a fresh session must claim it — \`trantor open\` starts one. If they want THIS resumed session to continue instead, they can ignore the handoff or clear it.\n`;
|
|
482
|
+
additionalContext += `</trantor-handoff-unclaimed>\n`;
|
|
471
483
|
} else if (handoff) {
|
|
472
484
|
process.stderr.write(`[trantor] ${isCompact ? "showing (not claiming, compact)" : "loaded"} pending handoff ${handoff.id}\n`);
|
|
473
485
|
// Baton claimed → supersede every OTHER instance of this durable identity (instance-keys
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// USAGE v2 — the Claude statusline sidechannel (docs/RESEARCH-orca-usage.md §1.1).
|
|
3
|
+
// Claude Code >=2.1.80 pipes a JSON blob (with `rate_limits`) into the statusLine command on
|
|
4
|
+
// every turn, piggybacked on the Messages API response — live usage that costs zero API budget.
|
|
5
|
+
// This forwarder reads that stdin, POSTs the windows to the hub's /usage/claude (signed), and
|
|
6
|
+
// prints NOTHING: it is designed to be tee'd ahead of the operator's real statusline command,
|
|
7
|
+
// never to be one. Every failure is swallowed — a usage forwarder must never break a statusline.
|
|
8
|
+
//
|
|
9
|
+
// Floor: one POST per session per 15s (stamp file) — the statusline ticks ~3x/sec while
|
|
10
|
+
// streaming, and the hub dedupes same-value posts inside 30s anyway.
|
|
11
|
+
import { readFileSync, writeFileSync, statSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
|
|
15
|
+
const FLOOR_MS = 15_000;
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
let raw = "";
|
|
19
|
+
for await (const c of process.stdin) raw += c;
|
|
20
|
+
const j = JSON.parse(raw);
|
|
21
|
+
const rl = j.rate_limits;
|
|
22
|
+
if (!rl || typeof rl !== "object") return; // most ticks carry none — free exit
|
|
23
|
+
const sid = String(j.session_id || j.sessionId || "nosession").replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 80);
|
|
24
|
+
const dir = join(homedir(), ".agent-bus");
|
|
25
|
+
const stamp = join(dir, `usage-claude-${sid}.stamp`);
|
|
26
|
+
try { if (Date.now() - statSync(stamp).mtimeMs < FLOOR_MS) return; } catch {}
|
|
27
|
+
try { mkdirSync(dir, { recursive: true }); writeFileSync(stamp, ""); } catch {}
|
|
28
|
+
|
|
29
|
+
const payload = {
|
|
30
|
+
configDir: process.env.CLAUDE_CONFIG_DIR || null,
|
|
31
|
+
fiveHour: rl.five_hour ?? rl.fiveHour ?? null,
|
|
32
|
+
sevenDay: rl.seven_day ?? rl.sevenDay ?? null,
|
|
33
|
+
// The fable/model-scoped window normally arrives via the OAuth poller, but accept the
|
|
34
|
+
// statusline-shaped variants too — schema drift degrades instead of going dark.
|
|
35
|
+
fable: rl.fable_weekly ?? rl.fable ?? null,
|
|
36
|
+
};
|
|
37
|
+
if (!payload.fiveHour && !payload.sevenDay && !payload.fable) return;
|
|
38
|
+
// PRIMARY: the local live cache — lib/balances.mjs merges it and skips the OAuth poll while
|
|
39
|
+
// it is fresh (Orca's isLiveClaudeUsageFresh, docs/RESEARCH-orca-usage.md §2). This is the
|
|
40
|
+
// path the app's footer actually reads (it shells the local CLI, not the hub).
|
|
41
|
+
try { writeFileSync(join(dir, "usage-claude-live.json"), JSON.stringify({ ts: Date.now(), ...payload })); } catch {}
|
|
42
|
+
// SECONDARY, best-effort: the hub copy, for dashboard surfaces that read hub state.
|
|
43
|
+
const { signedPost } = await import("./lib/api.mjs");
|
|
44
|
+
await signedPost("/usage/claude", payload, { timeoutMs: 2500 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
main().catch(() => {}).finally(() => process.exit(0));
|
package/hub.mjs
CHANGED
|
@@ -1642,6 +1642,29 @@ const server = http.createServer(async (req, res) => {
|
|
|
1642
1642
|
if (ts >= (state.balances?.ts || 0)) { state.balances = { ts, by: String(b.by || "").slice(0, 120), entries }; dirty = true; }
|
|
1643
1643
|
return json(res, 200, { ok: true });
|
|
1644
1644
|
}
|
|
1645
|
+
// USAGE v2: the Claude statusline sidechannel. Claude Code >=2.1.80 pipes rate_limits into
|
|
1646
|
+
// the statusLine command on every turn; hooks/statusline.mjs forwards it here (floored 15s
|
|
1647
|
+
// client-side). The live windows PATCH the cached balances snapshot — free usage between
|
|
1648
|
+
// `trantor balances` runs, and the poller can skip Claude while liveTs is fresh (Orca's
|
|
1649
|
+
// lesson, docs/RESEARCH-orca-usage.md §1.1: the OAuth endpoint 429s under polling).
|
|
1650
|
+
if (req.method === "POST" && P === "/usage/claude") {
|
|
1651
|
+
const b = await body(req);
|
|
1652
|
+
const win = (w, name) => (w && (w.used_percentage ?? w.utilization) != null)
|
|
1653
|
+
? { name, usedPct: Math.round(Number(w.used_percentage ?? w.utilization)), resetsAt: w.resets_at ?? null } : null;
|
|
1654
|
+
const wins = [["fiveHour", "5h"], ["sevenDay", "7d"], ["fable", "Fable"]]
|
|
1655
|
+
.map(([k, n]) => win(b[k], n)).filter(Boolean);
|
|
1656
|
+
if (!wins.length) return json(res, 400, { error: "no usable windows" });
|
|
1657
|
+
state.balances ||= { ts: 0, by: "", entries: [] };
|
|
1658
|
+
let e = state.balances.entries.find(x => x.provider === "claude");
|
|
1659
|
+
if (!e) { e = { provider: "claude", label: "Claude", kind: "windows", ok: true, windows: [] }; state.balances.entries.push(e); }
|
|
1660
|
+
// Same-value posts inside 30s are dropped (the statusline ticks ~3x/sec while streaming).
|
|
1661
|
+
const sig = JSON.stringify(wins);
|
|
1662
|
+
if (e._liveSig === sig && now() - (e.liveTs || 0) < 30_000) return json(res, 200, { ok: true, deduped: true });
|
|
1663
|
+
for (const w of wins) { const cur = (e.windows ||= []).find(x => x.name === w.name); if (cur) Object.assign(cur, w); else e.windows.push(w); }
|
|
1664
|
+
e.ok = true; e.liveTs = now(); e._liveSig = sig; e.liveSource = "statusline";
|
|
1665
|
+
dirty = true;
|
|
1666
|
+
return json(res, 200, { ok: true, windows: wins.length });
|
|
1667
|
+
}
|
|
1645
1668
|
if (req.method === "GET" && P === "/balances") {
|
|
1646
1669
|
let cfg = {}; try { cfg = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")); } catch {}
|
|
1647
1670
|
const low = { USD: 5, CNY: 35, EUR: 5, ...(cfg.lowBalance || {}) };
|
package/lib/balances.mjs
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
// ARCHITECTURE NOTE: the hub runs under launchd with a minimal env (no keys), so it can't fetch these
|
|
12
12
|
// itself. The env-having clients fetch + POST /balances; the hub caches + serves the dashboard.
|
|
13
13
|
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
|
|
14
18
|
const TIMEOUT = 8000;
|
|
15
19
|
|
|
16
20
|
async function getJSON(url, key, extraHeaders = {}) {
|
|
@@ -40,13 +44,40 @@ export const ADAPTERS = [
|
|
|
40
44
|
provider: "claude", label: "Claude", kind: "windows", match: ["claude", "anthropic"], envKeys: [],
|
|
41
45
|
keyless: true,
|
|
42
46
|
async fetch() {
|
|
47
|
+
// USAGE v2: the statusline sidechannel (hooks/statusline.mjs) keeps a live local cache —
|
|
48
|
+
// Claude Code pipes rate_limits to the statusLine every turn, free. When the OAuth
|
|
49
|
+
// endpoint fails (it 429s under polling — Orca's lesson, docs/RESEARCH-orca-usage.md
|
|
50
|
+
// §1.1), a fresh live capture answers instead of an error row. OAuth stays primary
|
|
51
|
+
// because only it carries the model-scoped (Fable) window.
|
|
52
|
+
const live = (() => {
|
|
53
|
+
try {
|
|
54
|
+
const l = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "usage-claude-live.json"), "utf8"));
|
|
55
|
+
return l && Date.now() - (l.ts || 0) < 15 * 60 * 1000 ? l : null;
|
|
56
|
+
} catch { return null; }
|
|
57
|
+
})();
|
|
58
|
+
const liveWin = (w, name) => (w && (w.used_percentage ?? w.utilization) != null)
|
|
59
|
+
? { name, usedPct: Math.round(Number(w.used_percentage ?? w.utilization)), resetsAt: w.resets_at || null, locked: null }
|
|
60
|
+
: null;
|
|
61
|
+
const liveWindows = () => [liveWin(live?.fiveHour, "5h"), liveWin(live?.sevenDay, "7d")].filter(Boolean);
|
|
43
62
|
const tok = await claudeOAuthToken();
|
|
44
|
-
if (!tok)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
if (!tok) {
|
|
64
|
+
if (live && liveWindows().length) return { windows: liveWindows(), live: true };
|
|
65
|
+
throw new Error("no Claude Code OAuth token found");
|
|
66
|
+
}
|
|
67
|
+
let r;
|
|
68
|
+
try {
|
|
69
|
+
r = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
70
|
+
headers: { authorization: `Bearer ${tok}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
71
|
+
signal: AbortSignal.timeout(8000),
|
|
72
|
+
});
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (live && liveWindows().length) return { windows: liveWindows(), live: true };
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
if (!r.ok) {
|
|
78
|
+
if (live && liveWindows().length) return { windows: liveWindows(), live: true };
|
|
79
|
+
throw new Error(`usage endpoint ${r.status}`);
|
|
80
|
+
}
|
|
50
81
|
const d = await r.json();
|
|
51
82
|
const win = (w, name) => (w && w.utilization != null)
|
|
52
83
|
? { name, usedPct: Math.round(w.utilization), resetsAt: w.resets_at || null, locked: w.locked_reason || null }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"zod": "^4.4.3"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
|
|
14
|
+
"test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
|
|
15
15
|
},
|
|
16
16
|
"description": "The hub-world for AI agent crews \u2014 orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
17
17
|
"files": [
|