trantor 0.18.36 → 0.18.38
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/baton.mjs +7 -3
- package/bin/crew-runner.mjs +65 -15
- package/bin/crew.sh +54 -3
- package/bin/policy.mjs +21 -1
- package/bin/slop-gate.mjs +15 -0
- package/hooks/lib/handoff.mjs +39 -5
- package/hub.mjs +69 -2
- package/lib/balances.mjs +56 -2
- package/lib/project.mjs +5 -2
- package/lib/turn-policy.mjs +36 -0
- package/mcp.mjs +1 -1
- package/package.json +1 -1
- package/skills/crew/SKILL.md +10 -0
- package/skills/prd-review/SKILL.md +8 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.38",
|
|
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/baton.mjs
CHANGED
|
@@ -17,6 +17,10 @@ import { writeHandoff, spawnBaton, resolveHandoffSurface } from "../hooks/lib/ha
|
|
|
17
17
|
const cwd = process.cwd();
|
|
18
18
|
const resolved = resolveHandoffSurface({ projectDir: process.env.CLAUDE_PROJECT_DIR || cwd, sessionId: process.env.CLAUDE_SESSION_ID || "" });
|
|
19
19
|
const project = resolved.project;
|
|
20
|
+
// #6218 — the transcript is found under the RESOLVED session directory (CLAUDE_PROJECT_DIR, the
|
|
21
|
+
// dir the session was launched in — how Claude itself names ~/.claude/projects), never a shell
|
|
22
|
+
// cwd that cd'd somewhere else: a handoff record must carry the transcript it was written from.
|
|
23
|
+
const sessionDir = resolved.projectDir;
|
|
20
24
|
|
|
21
25
|
// Model-authored handoffs ride THIS binary too (`trantor handoff` — always the global install's
|
|
22
26
|
// CURRENT code, never the plugin-cache copy a session booted with, the stale-0.18.20 bug). A piped
|
|
@@ -38,10 +42,10 @@ if (stdinIsPipe() || process.argv.includes("--latest")) {
|
|
|
38
42
|
autoBaton();
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
// The active session's transcript = newest *.jsonl directly in
|
|
42
|
-
// (~/.claude/projects/<
|
|
45
|
+
// The active session's transcript = newest *.jsonl directly in the session dir's Claude dir
|
|
46
|
+
// (~/.claude/projects/<sessionDir-with-slashes-as-dashes>/), excluding the subagents/ subtree.
|
|
43
47
|
function findTranscript() {
|
|
44
|
-
const dashed =
|
|
48
|
+
const dashed = sessionDir.replace(/\//g, "-");
|
|
45
49
|
const base = join(homedir(), ".claude", "projects");
|
|
46
50
|
let best = "", bestM = 0;
|
|
47
51
|
let dirs = []; try { dirs = readdirSync(base).filter(d => d === dashed || d.endsWith(dashed)); } catch {}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -24,7 +24,8 @@ import {
|
|
|
24
24
|
} from "../lib/classify-failure.mjs";
|
|
25
25
|
import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
|
|
26
26
|
import {
|
|
27
|
-
cardRef, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, PARKING_REASONS,
|
|
27
|
+
cardRef, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
|
|
28
|
+
senderProjectOf, isLinkedProject,
|
|
28
29
|
} from "../lib/turn-policy.mjs";
|
|
29
30
|
|
|
30
31
|
const AGENT = process.argv[2];
|
|
@@ -304,7 +305,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
|
|
|
304
305
|
|
|
305
306
|
// RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
|
|
306
307
|
// always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
|
|
307
|
-
const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing -> done; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach done with slop-gate failing; use 'failed' + a report if anything breaks). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus.`;
|
|
308
|
+
const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing -> done; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach done with slop-gate failing; use 'failed' + a report if anything breaks). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
|
|
308
309
|
|
|
309
310
|
// ---- the pulse (Scape's Lloyd/Argus loop, Trantor-shaped) --------------------
|
|
310
311
|
// A message-driven seat is DEAF between messages. An orchestrator seat with a mission needs a
|
|
@@ -435,8 +436,10 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
|
|
|
435
436
|
// redelivered to. So those two reasons PARK — the queue is kept, the ladder stops, and the room is
|
|
436
437
|
// told once, with the reset time when the CLI printed one. `trantor up` (a restart) resumes.
|
|
437
438
|
let parkAnnounced = false;
|
|
438
|
-
async function parkSeat(reason, undelivered) {
|
|
439
|
-
|
|
439
|
+
async function parkSeat(reason, undelivered, resetHint = 0) {
|
|
440
|
+
// A seat that went QUIET printed no wall message to parse (#6131), so its own balance row is the
|
|
441
|
+
// only place the reset time exists. Output first when there is any: it is this turn's evidence.
|
|
442
|
+
const resetAt = parseResetAt(lastErrText) || resetHint;
|
|
440
443
|
const when = resetAt ? new Date(resetAt).toLocaleString() : "";
|
|
441
444
|
if (!parkAnnounced) {
|
|
442
445
|
parkAnnounced = true;
|
|
@@ -456,8 +459,12 @@ async function parkSeat(reason, undelivered) {
|
|
|
456
459
|
async function balanceRows() {
|
|
457
460
|
try {
|
|
458
461
|
const { fetchBalances } = await import("../lib/balances.mjs");
|
|
462
|
+
// Same key sources the crew itself uses: QWEN_API_KEY (and most others) live in
|
|
463
|
+
// ~/.agent-bus/.env, not in the runner's inherited environment — reading bare process.env
|
|
464
|
+
// here would report "no key" and quietly leave every silent turn classified as a crash.
|
|
465
|
+
const { resolveKeys } = await import("../lib/provider-keys.mjs");
|
|
459
466
|
return await Promise.race([
|
|
460
|
-
fetchBalances(process.env, { only: [AGENT] }),
|
|
467
|
+
fetchBalances(resolveKeys(process.env), { only: [AGENT] }),
|
|
461
468
|
new Promise((r) => setTimeout(() => r([]), 4000)),
|
|
462
469
|
]);
|
|
463
470
|
} catch { return []; }
|
|
@@ -630,7 +637,7 @@ wait $job; turn_exit=$?
|
|
|
630
637
|
[ -n "$boxpid" ] && kill $boxpid 2>/dev/null
|
|
631
638
|
wait
|
|
632
639
|
exit $turn_exit`;
|
|
633
|
-
const
|
|
640
|
+
const spawnOpts = {
|
|
634
641
|
// detached: bash leads its OWN process group, so the time box can kill the CLI and everything
|
|
635
642
|
// it spawned with one signal instead of orphaning the model process behind a dead shell.
|
|
636
643
|
// stdin is /dev/null for every seat (it already was for codex/kimi/dsh via `< /dev/null`):
|
|
@@ -638,12 +645,14 @@ exit $turn_exit`;
|
|
|
638
645
|
// takes SIGTTIN and stops forever. Nothing here runs interactively — every CLI is in -p /
|
|
639
646
|
// exec / run mode — so closing stdin is what makes the group safe.
|
|
640
647
|
detached: true,
|
|
641
|
-
// A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
|
|
642
|
-
// still ends the turn. When the in-shell box works — the normal path — this never fires, which
|
|
643
|
-
// is the point: the shell kills while the tree is still walkable, node cannot.
|
|
644
|
-
...(TURN_MAX_MS ? { timeout: TURN_MAX_MS + 30000, killSignal: "SIGKILL" } : {}),
|
|
645
648
|
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
|
|
646
649
|
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
|
|
650
|
+
// #6228: badges this seat's env as belonging to PROJ, distinctly from a one-off RELAY_PROJECT
|
|
651
|
+
// override (bin/crew.sh's own tests, and any deliberate `RELAY_PROJECT=x trantor up` from a
|
|
652
|
+
// plain shell, set RELAY_PROJECT alone and must keep working — only THIS marker means "the
|
|
653
|
+
// env I'm running in already has a project home"). crew.sh's `up` guard refuses to bring up a
|
|
654
|
+
// DIFFERENT project's crew from a shell carrying this badge, same as it refuses TRANTOR_ORCH.
|
|
655
|
+
TRANTOR_SEAT: PROJ,
|
|
647
656
|
// A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
|
|
648
657
|
//
|
|
649
658
|
// The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
|
|
@@ -656,7 +665,12 @@ exit $turn_exit`;
|
|
|
656
665
|
// sitting there days later. To the operator that reads as "why are there two duty agents".
|
|
657
666
|
TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
|
|
658
667
|
maxBuffer: 16 * 1024 * 1024,
|
|
659
|
-
}
|
|
668
|
+
};
|
|
669
|
+
// A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
|
|
670
|
+
// still ends the turn. When the in-shell box works — the normal path — this never fires, which
|
|
671
|
+
// is the point: the shell kills while the tree is still walkable, node cannot.
|
|
672
|
+
if (TURN_MAX_MS) { spawnOpts.timeout = TURN_MAX_MS + 30000; spawnOpts.killSignal = "SIGKILL"; }
|
|
673
|
+
const r = spawnSync("/bin/bash", ["-c", shell], spawnOpts);
|
|
660
674
|
try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
|
|
661
675
|
// The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
|
|
662
676
|
// cut, not merely failed.
|
|
@@ -722,7 +736,10 @@ exit $turn_exit`;
|
|
|
722
736
|
// #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
|
|
723
737
|
// never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
|
|
724
738
|
const tokens = parseTurnTokens(ownOut);
|
|
725
|
-
|
|
739
|
+
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 };
|
|
740
|
+
if (tokens) telemetryRow.tokens = tokens;
|
|
741
|
+
if (cut) telemetryRow.cut = true;
|
|
742
|
+
telemetry(telemetryRow);
|
|
726
743
|
log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
727
744
|
if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
|
|
728
745
|
// #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
|
|
@@ -827,6 +844,20 @@ function shouldWake(message) {
|
|
|
827
844
|
&& (message.text.includes(`@${AGENT}`) || message.text.toLowerCase().includes(`${AGENT}:`));
|
|
828
845
|
}
|
|
829
846
|
|
|
847
|
+
// #6228: the operator's declared project links, cached briefly so a burst of wakes doesn't hit
|
|
848
|
+
// /policy per message. Fails CLOSED on an unreachable hub (keeps whatever was last known, empty
|
|
849
|
+
// on a cold start) — the mechanical fence the hub enforces at write time must not go soft just
|
|
850
|
+
// because the read-side cache call happened to miss.
|
|
851
|
+
let linksCache = { at: 0, links: [] };
|
|
852
|
+
async function currentLinks() {
|
|
853
|
+
if (Date.now() - linksCache.at < 60000) return linksCache.links;
|
|
854
|
+
try {
|
|
855
|
+
const r = await api("/policy");
|
|
856
|
+
linksCache = { at: Date.now(), links: Array.isArray(r?.links) ? r.links : [] };
|
|
857
|
+
} catch {}
|
|
858
|
+
return linksCache.links;
|
|
859
|
+
}
|
|
860
|
+
|
|
830
861
|
function askedExcerpt(message) {
|
|
831
862
|
let text = String(message?.text || "").replace(/\s+/g, " ").trim();
|
|
832
863
|
const nested = text.search(/\s+[·|]\s*asked\s*:/i);
|
|
@@ -932,7 +963,19 @@ function askedExcerpt(message) {
|
|
|
932
963
|
// deafness problem: the seat would never learn what it was told (#6134).
|
|
933
964
|
const bcast = [...rest.filter(m => !direct.includes(m) && !mentions.includes(m)), ...fyi];
|
|
934
965
|
pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
|
|
935
|
-
const
|
|
966
|
+
const wakeCandidates = [...direct, ...mentions];
|
|
967
|
+
// #6228: a wake naming another project (its sender's home project, not this seat's, and the
|
|
968
|
+
// two are not `trantor policy link`ed) is dropped without acting — never queued, never folded
|
|
969
|
+
// into context. One report goes back to the sender so it does not just look like silence.
|
|
970
|
+
const links = wakeCandidates.length ? await currentLinks() : [];
|
|
971
|
+
const crossProject = wakeCandidates.filter(m => !isLinkedProject(senderProjectOf(m.from), PROJ, links));
|
|
972
|
+
for (const m of crossProject) {
|
|
973
|
+
const sp = senderProjectOf(m.from) || "?";
|
|
974
|
+
log(`\x1b[33mcross-project wake dropped\x1b[0m — ${m.from} (${sp}) is not ${PROJ}'s project and the two are not linked`);
|
|
975
|
+
api("/send", { from: SESSION, to: m.from, project: sp, kind: "status",
|
|
976
|
+
text: `⛔ cross-project: ${SESSION} is ${PROJ}'s seat, not ${sp}'s — dropped without acting. Link them first: trantor policy link ${PROJ} ${sp} --reason "<why>"` }).catch(() => {});
|
|
977
|
+
}
|
|
978
|
+
const wake = wakeCandidates.filter(m => !crossProject.includes(m));
|
|
936
979
|
if (!wake.length) { if (bcast.length) { savePending(pendingWake, pendingBcast); log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); } continue; }
|
|
937
980
|
// Queue BEFORE running the turn, and persist immediately. Everything between here and a clean
|
|
938
981
|
// exit 0 — the CLI dying, the machine losing power — now leaves a record of what this seat owes.
|
|
@@ -998,11 +1041,18 @@ function askedExcerpt(message) {
|
|
|
998
1041
|
// #6131: a silent turn on a seat whose plan reads spent is exhaustion wearing a crash's
|
|
999
1042
|
// clothes. Only that one reason is ever re-read, and only from the seat's own balance rows.
|
|
1000
1043
|
let reason = classify(ec);
|
|
1001
|
-
|
|
1044
|
+
let quotaReset = 0;
|
|
1045
|
+
if (reason === "empty-output") {
|
|
1046
|
+
const rows = await balanceRows();
|
|
1047
|
+
reason = reasonWithBalances(reason, rows);
|
|
1048
|
+
// The rows that just proved the plan is spent also carry when it lifts — a park that names
|
|
1049
|
+
// the reset is a wait, a park that cannot is a seat the operator has to remember by hand.
|
|
1050
|
+
if (reason === "exhausted") quotaReset = quotaResetAt(rows);
|
|
1051
|
+
}
|
|
1002
1052
|
savePending(pendingWake, pendingBcast);
|
|
1003
1053
|
await reportFailure(ec, "message", pendingWake.length, reason);
|
|
1004
1054
|
if (PARKING_REASONS.has(reason)) {
|
|
1005
|
-
retryAt = await parkSeat(reason, pendingWake.length);
|
|
1055
|
+
retryAt = await parkSeat(reason, pendingWake.length, quotaReset);
|
|
1006
1056
|
await notifyAssigners(assigners,
|
|
1007
1057
|
`⛔ your contract is PARKED on ${SESSION} (${reason}) — not retrying · asked: "${asked}"`);
|
|
1008
1058
|
lastTurnAt = Date.now();
|
package/bin/crew.sh
CHANGED
|
@@ -76,6 +76,40 @@ SEATDIR="$HOME/.agent-bus/seats"; mkdir -p "$SEATDIR"
|
|
|
76
76
|
DRY="${CREW_DRY_RUN:-0}"
|
|
77
77
|
run() { if [ "$DRY" = "1" ]; then echo "[dry] $*"; else eval "$*"; fi; }
|
|
78
78
|
|
|
79
|
+
# ── cross-project guard (#6228) ────────────────────────────────────────────────────────────────────
|
|
80
|
+
# The pr-os incident: an orchestrator pane badged TRANTOR_ORCH=pr-os ran `trantor up` while cd'd into
|
|
81
|
+
# the crebral-com worktree and brought up seats there under its OWN session. TRANTOR_ORCH (set once by
|
|
82
|
+
# `trantor open`, never per-invocation) and TRANTOR_SEAT (set once by crew-runner.mjs on every seat it
|
|
83
|
+
# spawns, see bin/crew-runner.mjs) are both STABLE badges of "this shell already has a project home" —
|
|
84
|
+
# unlike RELAY_PROJECT alone, which this same script's own tests legitimately override per-invocation
|
|
85
|
+
# to name a target explicitly (a bare `RELAY_PROJECT=x trantor up` from an unbadged shell is the
|
|
86
|
+
# operator, not a breach, and must keep working). A badged shell whose badge disagrees with the `up`
|
|
87
|
+
# target refuses mechanically — the CLI belt to the hub's own 403 (hub.mjs crossProjectGuard).
|
|
88
|
+
# A declared `trantor policy link` opens the door, same exception the hub and runner honor — so this
|
|
89
|
+
# belt must check, not just refuse on sight. `policy.mjs check` asks the hub; CREW_TEST_POLICY_LINKS
|
|
90
|
+
# is a hermetic override for test-crew.sh (comma-separated `a:b` pairs) so the drill never depends on
|
|
91
|
+
# a real hub being reachable, or on nothing already listening on the default loopback port.
|
|
92
|
+
if [ "$CMD" = "up" ]; then
|
|
93
|
+
BADGE="${TRANTOR_ORCH:-${TRANTOR_SEAT:-}}"
|
|
94
|
+
if [ -n "$BADGE" ] && [ "$BADGE" != "$PROJ" ]; then
|
|
95
|
+
LINKED=0
|
|
96
|
+
if [ -n "${CREW_TEST_POLICY_LINKS+x}" ]; then
|
|
97
|
+
case ",${CREW_TEST_POLICY_LINKS}," in
|
|
98
|
+
*",$BADGE:$PROJ,"*|*",$PROJ:$BADGE,"*) LINKED=1 ;;
|
|
99
|
+
esac
|
|
100
|
+
elif node "$BUS_DIR/bin/policy.mjs" check "$BADGE" "$PROJ" >/dev/null 2>&1; then
|
|
101
|
+
LINKED=1
|
|
102
|
+
fi
|
|
103
|
+
if [ "$LINKED" != "1" ]; then
|
|
104
|
+
echo "trantor: refused — this shell is badged for '$BADGE', not '$PROJ'." >&2
|
|
105
|
+
echo " Cross-project action is a breach unless the operator linked the projects." >&2
|
|
106
|
+
echo " Run: trantor policy link $BADGE $PROJ --reason \"<why>\"" >&2
|
|
107
|
+
exit 1
|
|
108
|
+
fi
|
|
109
|
+
echo "trantor: '$BADGE' ↔ '$PROJ' is policy-linked — proceeding." >&2
|
|
110
|
+
fi
|
|
111
|
+
fi
|
|
112
|
+
|
|
79
113
|
# ── STATE helpers ──────────────────────────────────────────────────────────────────────────────────
|
|
80
114
|
# Row schema (TSV): PROJECT <TAB> KIND <TAB> AGENT <TAB> HANDLE
|
|
81
115
|
# KIND=win HANDLE=Terminal window id (one row per agent)
|
|
@@ -166,12 +200,22 @@ _herdr_ws_create() { # $1=cwd $2=label → "workspace_id<TAB>root_pane_id"
|
|
|
166
200
|
let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const r=(JSON.parse(d.slice(d.search(/[\[{]/))).result)||{};
|
|
167
201
|
process.stdout.write((((r.workspace||{}).workspace_id)||"")+"\t"+(((r.root_pane||{}).pane_id)||""))}catch(e){}})'
|
|
168
202
|
}
|
|
169
|
-
_herdr_split() { # $1=pane id ("" = UI-focused pane) $2=right|down → new pane id
|
|
170
|
-
local a=(pane split); [ -n "$1" ] && a+=("$1"); a+=(--direction "$2" --no-focus)
|
|
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")
|
|
171
205
|
_herdr "${a[@]}" 2>/dev/null | node -e '
|
|
172
206
|
let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const r=(JSON.parse(d.slice(d.search(/[\[{]/))).result)||{};
|
|
173
207
|
process.stdout.write(((r.pane||{}).pane_id)||"")}catch(e){}})'
|
|
174
208
|
}
|
|
209
|
+
# Any live pane INSIDE a workspace (the one whose cwd is $2 first). The orchestrator pane must be
|
|
210
|
+
# hosted off a pane of ITS OWN workspace: splitting the UI-focused pane put crebral-com's
|
|
211
|
+
# orchestrator in the trantor window, in a trantor shell, twice on 2026-09-03 (the operator was
|
|
212
|
+
# looking at trantor when Wake ran) — twin bus identity, wrong checkout, crossed wires.
|
|
213
|
+
_herdr_ws_pane() { # $1=workspace id $2=preferred cwd → pane id ("" if none)
|
|
214
|
+
_herdr pane list 2>/dev/null | WS="$1" CWD="$2" node -e '
|
|
215
|
+
let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));
|
|
216
|
+
const a=(Array.isArray(o)?o:(o.panes||((o.result||{}).panes)||[])).filter(p=>(p.workspace_id||p.workspace||"")===process.env.WS);
|
|
217
|
+
const m=a.find(p=>(p.cwd||"")===process.env.CWD)||a[0];process.stdout.write(m?(m.pane_id||m.id||""):"")}catch(e){}})'
|
|
218
|
+
}
|
|
175
219
|
_herdr_ws_live() { # workspace list → "ids<TABnewline>names" (names \x01-wrapped+joined, like cmux)
|
|
176
220
|
_herdr workspace list 2>/dev/null | node -e '
|
|
177
221
|
let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));
|
|
@@ -697,12 +741,19 @@ open_orchestrator() {
|
|
|
697
741
|
# Host the pane: a JUST-created workspace's root pane IS the orchestrator pane (claude rides it);
|
|
698
742
|
# an existing workspace gets a split — the crew's seats tile off it on the next `up`, never
|
|
699
743
|
# replacing it (spawn_herdr's REUSE mode splits seats off their own old pane / the previous one).
|
|
744
|
+
# An EXISTING workspace hosts the split off one of ITS panes, in the project's checkout — never off
|
|
745
|
+
# the UI-focused pane, which is whatever the operator happens to be looking at (2026-09-03).
|
|
700
746
|
if [ "$DRY" = "1" ]; then
|
|
701
747
|
orch="%DRYORCH"
|
|
748
|
+
[ "$fresh" = "1" ] || echo "[dry] herdr: pane split %DRYHOST($wsid) --direction right --cwd $DIR" >&2
|
|
702
749
|
echo "[dry] herdr: ${fresh:+root pane + }pane rename $orch 'orchestrator · $PROJ' + run '$(_orch_cmd "$DIR" "$sid")'" >&2
|
|
703
750
|
else
|
|
704
751
|
if [ "$fresh" = "1" ]; then orch="${pair##*$'\t'}"
|
|
705
|
-
else
|
|
752
|
+
else
|
|
753
|
+
local host; host="$(_herdr_ws_pane "$wsid" "$DIR")"
|
|
754
|
+
[ -n "$host" ] || { echo "trantor open: workspace $wsid has no live pane to host the orchestrator" >&2; exit 1; }
|
|
755
|
+
orch="$(_herdr_split "$host" right "$DIR")"
|
|
756
|
+
fi
|
|
706
757
|
[ -n "$orch" ] || { echo "trantor open: could not create the orchestrator pane" >&2; exit 1; }
|
|
707
758
|
_herdr pane rename "$orch" "orchestrator · $PROJ" >/dev/null 2>&1
|
|
708
759
|
_herdr pane run "$orch" "$(_orch_cmd "$DIR" "$sid")" >/dev/null 2>&1
|
package/bin/policy.mjs
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
// trantor policy — the autonomy ladder's admin surface (PRD §6): show levels + links,
|
|
3
3
|
// set a project's level, declare that two projects are codependent.
|
|
4
4
|
// trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>" | unlink <a> <b>
|
|
5
|
+
// trantor policy check <a> <b> — exit 0 (prints "yes") if linked or identical, else exit 1 ("no")
|
|
5
6
|
// Drafted by scrooge (deepseek-v4-flash), integrated by the orchestrator.
|
|
7
|
+
// `check` is what bin/crew.sh's cross-project guard (#6228) shells out to: the CLI belt needs to
|
|
8
|
+
// know whether the operator already linked the two projects before it refuses `trantor up`.
|
|
6
9
|
import { readFileSync } from "node:fs";
|
|
7
10
|
import { join } from "node:path";
|
|
8
11
|
import { homedir } from "node:os";
|
|
@@ -30,7 +33,7 @@ const post = async (hub, payload) => {
|
|
|
30
33
|
|
|
31
34
|
const legend = { 1: "1 observe", 2: "2 warn", 3: "3 gate", 4: "4 auto" };
|
|
32
35
|
function usage() {
|
|
33
|
-
console.log('usage: trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>" | unlink <a> <b>');
|
|
36
|
+
console.log('usage: trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>" | unlink <a> <b> | check <a> <b>');
|
|
34
37
|
process.exit(1);
|
|
35
38
|
}
|
|
36
39
|
|
|
@@ -80,4 +83,21 @@ if (cmd === "unlink") {
|
|
|
80
83
|
process.exit(0);
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
if (cmd === "check") {
|
|
87
|
+
if (!arg1 || !arg2) usage();
|
|
88
|
+
if (arg1 === arg2) { console.log("yes"); process.exit(0); }
|
|
89
|
+
let linked = false;
|
|
90
|
+
for (const hub of hubs) {
|
|
91
|
+
try {
|
|
92
|
+
const data = await get(hub);
|
|
93
|
+
if ((data.links || []).some((l) => {
|
|
94
|
+
const ps = (l.projects || []).map((p) => String(p).toLowerCase());
|
|
95
|
+
return ps.includes(arg1.toLowerCase()) && ps.includes(arg2.toLowerCase());
|
|
96
|
+
})) { linked = true; break; }
|
|
97
|
+
} catch { /* unreachable hub: fails closed, not linked */ }
|
|
98
|
+
}
|
|
99
|
+
console.log(linked ? "yes" : "no");
|
|
100
|
+
process.exit(linked ? 0 : 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
usage();
|
package/bin/slop-gate.mjs
CHANGED
|
@@ -43,6 +43,21 @@ if (SURFACE) {
|
|
|
43
43
|
// excluded legacy files (hub.mjs, mcp.mjs) stay excluded here too.
|
|
44
44
|
const r = spawnSync("npx", ["oxlint", ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
45
45
|
const out = (r.stdout || "") + (r.stderr || "");
|
|
46
|
+
// A gate that cannot run must not read as clean. Witnessed 2026-09-03: a seat worktree with no
|
|
47
|
+
// node_modules made `npx oxlint` die with an npm error, the empty output had zero hits, and
|
|
48
|
+
// "slop-gate clean" rode into main on 23 real anti-slop errors. the exit contract below is the
|
|
49
|
+
// only proof it ran; without it, exit 2 and say so.
|
|
50
|
+
// oxlint prints no run summary here (diagnostics only), so "it ran" is read from its exit
|
|
51
|
+
// contract: 0 = clean, 1 = diagnostics printed. An npm error, a launch error, an exit code oxlint
|
|
52
|
+
// never uses, a 1 with no diagnostic line, or "No files found" is not a verdict.
|
|
53
|
+
const diag = /:\d+:\d+: (error|warning)\b/.test(out);
|
|
54
|
+
const ran = !r.error && !/^npm (error|ERR!)/m.test(out) && !/No files found to lint/.test(out)
|
|
55
|
+
&& (r.status === 0 || (r.status === 1 && diag));
|
|
56
|
+
if (!ran) {
|
|
57
|
+
console.error(out.trim().split("\n").slice(-6).join("\n"));
|
|
58
|
+
console.error("\nslop-gate: could not run oxlint, so there is no verdict and this is NOT clean. Install deps in this checkout (pnpm install) and rerun.");
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
46
61
|
const hits = out.split("\n").filter(l => /error\s+anti-slop\(/.test(l));
|
|
47
62
|
|
|
48
63
|
if (hits.length) {
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// wall when we know the window size. Both paths share a per-session guard so we
|
|
12
12
|
// never write/spawn twice for the same context window.
|
|
13
13
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, fstatSync, closeSync, rmSync } from "node:fs";
|
|
14
|
-
import { join, basename, dirname } from "node:path";
|
|
14
|
+
import { join, basename, dirname, sep } from "node:path";
|
|
15
15
|
import { homedir, hostname } from "node:os";
|
|
16
16
|
import { execSync, spawn } from "node:child_process";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -621,18 +621,52 @@ export function orchProjectForSession(sid) {
|
|
|
621
621
|
return "";
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
+
// Does the cwd actually lie inside THIS project's ground (#6218)? Two true homes: a directory
|
|
625
|
+
// named for the project (the project root or anything below it — the #6074 subfolder case), and
|
|
626
|
+
// the project's agent-bus worktrees (<bus>/worktrees/<project>/<seat>). Pure + exported so the
|
|
627
|
+
// drill can pin both without touching the operator's disk layout.
|
|
628
|
+
export function cwdInsideProject(projectName, dir, bus = process.env.AGENT_BUS_DIR || process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus")) {
|
|
629
|
+
if (!projectName || !dir) return false;
|
|
630
|
+
const wt = join(bus, "worktrees", projectName);
|
|
631
|
+
if (dir === wt || dir.startsWith(wt + sep)) return true;
|
|
632
|
+
let cur = dir;
|
|
633
|
+
for (;;) {
|
|
634
|
+
if (basename(cur) === projectName) return true;
|
|
635
|
+
const parent = dirname(cur);
|
|
636
|
+
if (parent === cur) return false;
|
|
637
|
+
cur = parent;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
624
641
|
// The one resolver. Returns { project, projectDir, pane, surface }:
|
|
625
642
|
// pane — the session's own pane id ("" when it is not a hosted pane)
|
|
626
643
|
// surface — "pane" (the baton MUST take the pane leg) or "window" (today's behavior)
|
|
627
644
|
// project — the REGISTERED project name; a subfolder cwd never renames it
|
|
645
|
+
// #6218 — the badge wins only where it is TRUE. Witnessed 2026-09-03: a run from a
|
|
646
|
+
// trantor-badged shell in ~/development/tiny-timer wrote "handoff saved for trantor" with
|
|
647
|
+
// tiny-timer's transcript inside. #6074's registration-before-cwd rule was written for a
|
|
648
|
+
// subfolder cwd of the SAME project; a FOREIGN project dir is a different case and must not be
|
|
649
|
+
// silently relabeled. So the badge (and the rest of the registration chain behind it) claims
|
|
650
|
+
// this handoff only when the cwd lies inside the named project's directory or one of its
|
|
651
|
+
// worktrees; a badged shell sitting elsewhere resolves from the cwd, and ONE warning line names
|
|
652
|
+
// both — the badge that lied and the project the record actually went to.
|
|
628
653
|
export function resolveHandoffSurface({ projectDir, sessionId, env = process.env } = {}) {
|
|
629
654
|
const dir = projectDir || env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
630
655
|
const badge = String(env.TRANTOR_ORCH || "").trim();
|
|
631
656
|
let project = "";
|
|
632
|
-
|
|
633
|
-
if (
|
|
634
|
-
|
|
635
|
-
|
|
657
|
+
let foreignBadge = "";
|
|
658
|
+
if (badge && badge !== "1") { // `trantor open` badge carries the name
|
|
659
|
+
if (cwdInsideProject(badge, dir)) project = badge;
|
|
660
|
+
else foreignBadge = badge; // the badge lies about this cwd
|
|
661
|
+
}
|
|
662
|
+
if (!project && !foreignBadge && env.RELAY_PROJECT) project = String(env.RELAY_PROJECT).trim();
|
|
663
|
+
if (!project && !foreignBadge) project = orchProjectForSession(sessionId);
|
|
664
|
+
// Last resort: the cwd (git-root aware). With a foreign badge, the shell's own RELAY_PROJECT
|
|
665
|
+
// is scrubbed from the fallback's env — it is the same registration that just lied (#6218).
|
|
666
|
+
if (!project) project = resolveProject(dir, foreignBadge ? { ...env, RELAY_PROJECT: "" } : env);
|
|
667
|
+
if (foreignBadge) {
|
|
668
|
+
console.error(`trantor handoff: TRANTOR_ORCH=${foreignBadge} but the working directory (${dir}) is not inside ${foreignBadge}'s project or its worktrees — saving the handoff for ${project} (resolved from the cwd)`);
|
|
669
|
+
}
|
|
636
670
|
return { project, projectDir: dir, pane: paneSurfaceEnv(env), surface: paneSurfaceEnv(env) ? "pane" : "window" };
|
|
637
671
|
}
|
|
638
672
|
|
package/hub.mjs
CHANGED
|
@@ -1101,6 +1101,61 @@ function projectFromRequest(P, q, b) {
|
|
|
1101
1101
|
}
|
|
1102
1102
|
return canon(String(b?.project || q?.project || "").slice(0, 80));
|
|
1103
1103
|
}
|
|
1104
|
+
// --- Cross-project guard (#6228) -----------------------------------------------------------
|
|
1105
|
+
// scopeAllows/canRead answer "can this identity touch project P at all" — and most identities
|
|
1106
|
+
// are minted project:"*" role:"owner" by defaultScopesFor (any non-agent kind: an orchestrator,
|
|
1107
|
+
// genesis, a human). That is not a project fence; it is exactly the loophole the pr-os
|
|
1108
|
+
// orchestrator walked through to register seats and post contracts into crebral-com from its
|
|
1109
|
+
// own session, nothing it was ever working. This is the fence: does the CALLER's own project
|
|
1110
|
+
// (its identity name's "kind:project" suffix, the same convention defaultScopesFor reads) match
|
|
1111
|
+
// the project the request ACTS on. Only a declared `trantor policy link` (state.orgPolicy.links,
|
|
1112
|
+
// the same store /policy reads and writes — POST /policy is itself OWNER_ENDPOINTS-gated) or the
|
|
1113
|
+
// operator's own identity (kind "human", the key /instance/supersede already treats as owner)
|
|
1114
|
+
// opens the door. Applies to the five endpoints that reach across sessions or mint access:
|
|
1115
|
+
// /send, /task, /task/update, /register, /invite.
|
|
1116
|
+
const CROSS_PROJECT_ENDPOINTS = new Set(["/send", "/task", "/task/update", "/register", "/invite"]);
|
|
1117
|
+
function projectsLinked(a, b) {
|
|
1118
|
+
if (!a || !b || a === b) return true;
|
|
1119
|
+
return overseerPolicy().links.some(l => {
|
|
1120
|
+
const ps = (l.projects || []).map(p => canon(p));
|
|
1121
|
+
return ps.includes(a) && ps.includes(b);
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
// The caller's home project, by the SAME "name suffix after the colon" rule defaultScopesFor
|
|
1125
|
+
// uses to mint a fresh identity's default scope. An identity with no colon in its name (a bare
|
|
1126
|
+
// human alias, or a tool identity never given a project) has no home to fence — nothing to check.
|
|
1127
|
+
function callerProject(auth) {
|
|
1128
|
+
const name = String(auth?.identity?.name || "");
|
|
1129
|
+
return name.includes(":") ? canon(name.slice(name.lastIndexOf(":") + 1)) : "";
|
|
1130
|
+
}
|
|
1131
|
+
function crossProjectTarget(P, b) {
|
|
1132
|
+
if (P === "/send") {
|
|
1133
|
+
if (!b?.to || b.to === "all") return ""; // broadcast: existing hub-wide behavior, untouched
|
|
1134
|
+
const to = String(b.to);
|
|
1135
|
+
return canon(state.peers[to]?.project || (to.includes(":") ? to.slice(to.lastIndexOf(":") + 1) : ""));
|
|
1136
|
+
}
|
|
1137
|
+
if (P === "/task" || P === "/register") return canon(String(b?.project || "").slice(0, 80));
|
|
1138
|
+
if (P === "/task/update") {
|
|
1139
|
+
const t = state.tasks.find(x => x.id === Number(b?.id));
|
|
1140
|
+
return t?.project || "";
|
|
1141
|
+
}
|
|
1142
|
+
if (P === "/invite") {
|
|
1143
|
+
// an invite MINTS access into whatever project(s) its scopes name — a wildcard scope names none.
|
|
1144
|
+
const scopes = Array.isArray(b?.scopes) ? b.scopes : [];
|
|
1145
|
+
return scopes.map(s => canon(String(s?.project || ""))).find(p => p && p !== "*") || "";
|
|
1146
|
+
}
|
|
1147
|
+
return "";
|
|
1148
|
+
}
|
|
1149
|
+
function crossProjectGuard(auth, P, b) {
|
|
1150
|
+
if (!CROSS_PROJECT_ENDPOINTS.has(P) || AUTH_MODE === "off" || !auth?.identity) return { ok: true };
|
|
1151
|
+
if (auth.identity.kind === "human") return { ok: true }; // the operator's own key
|
|
1152
|
+
const home = callerProject(auth);
|
|
1153
|
+
if (!home) return { ok: true };
|
|
1154
|
+
const target = crossProjectTarget(P, b);
|
|
1155
|
+
if (!target || target === home || projectsLinked(home, target)) return { ok: true };
|
|
1156
|
+
return { ok: false, code: 403,
|
|
1157
|
+
error: `cross-project: ${home} may not act on ${target} — cross-project action is a breach unless the operator linked the projects. Run: trantor policy link ${home} ${target} --reason "<why>"` };
|
|
1158
|
+
}
|
|
1104
1159
|
// Is a stored baton claim still worth honouring? The claim names the instance it spared
|
|
1105
1160
|
// (`exceptInstanceId`), so we defer to THAT instance only while it is still being seen. A claimant
|
|
1106
1161
|
// that died stops muzzling its twins; one that comes back starts again. Records claimed before
|
|
@@ -1516,6 +1571,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1516
1571
|
}
|
|
1517
1572
|
const scopes = (Array.isArray(bi.scopes) ? bi.scopes : []).map(cleanScope).filter(Boolean).slice(0, 20);
|
|
1518
1573
|
if (!scopes.length) return json(res, 400, { error: "scopes required" });
|
|
1574
|
+
const cpg = crossProjectGuard(auth, P, { scopes });
|
|
1575
|
+
if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
|
|
1519
1576
|
// Honour the requested TTL. A 60s FLOOR here silently inflated `ttlSec: 1` to a minute, so a
|
|
1520
1577
|
// token the caller asked to expire in a second stayed valid — and an expired-token test passed
|
|
1521
1578
|
// a live token straight through /enroll. Cap the ceiling, never the floor.
|
|
@@ -1530,7 +1587,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1530
1587
|
const authz = authorize(auth, req.method, P, projectFromRequest(P, q, b0));
|
|
1531
1588
|
if (!authz.ok) return json(res, authz.code || 403, { error: authz.error || "forbidden" });
|
|
1532
1589
|
if (req.method === "POST" && P === "/register") {
|
|
1533
|
-
const b = await body(req);
|
|
1590
|
+
const b = await body(req);
|
|
1591
|
+
const cpg = crossProjectGuard(auth, P, b);
|
|
1592
|
+
if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
|
|
1593
|
+
touch(b.session, b.status, b.project, b.hookVersion, auth);
|
|
1534
1594
|
// WHO is this, really: the LLM brand + the exact model currently loaded. In-memory like the
|
|
1535
1595
|
// rest of presence — the next heartbeat re-supplies it after a restart.
|
|
1536
1596
|
const pr = state.peers[b.session];
|
|
@@ -1932,7 +1992,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1932
1992
|
}
|
|
1933
1993
|
// --- Kanban tasks ---
|
|
1934
1994
|
if (req.method === "POST" && P === "/task") { // create a card
|
|
1935
|
-
const b = await body(req);
|
|
1995
|
+
const b = await body(req);
|
|
1996
|
+
const cpg = crossProjectGuard(auth, P, b);
|
|
1997
|
+
if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
|
|
1998
|
+
touch(b.by, undefined, b.project, undefined, auth);
|
|
1936
1999
|
if (b.title !== undefined) b.title = stripNulText(b.title);
|
|
1937
2000
|
if (b.note !== undefined) b.note = stripNulText(b.note);
|
|
1938
2001
|
const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
|
|
@@ -2088,6 +2151,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2088
2151
|
if (req.method === "POST" && P === "/task/update") { // move/edit a card
|
|
2089
2152
|
const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
|
|
2090
2153
|
if (!t) return json(res, 404, { error: "no such task" });
|
|
2154
|
+
const cpg = crossProjectGuard(auth, P, b);
|
|
2155
|
+
if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
|
|
2091
2156
|
if (b.title !== undefined) b.title = stripNulText(b.title);
|
|
2092
2157
|
if (b.note !== undefined) b.note = stripNulText(b.note);
|
|
2093
2158
|
// Board integrity (#5406): a card can never change hands silently. The assignee is frozen once
|
|
@@ -2790,6 +2855,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2790
2855
|
const secretCheck = assertNoSecrets(text);
|
|
2791
2856
|
if (!secretCheck.ok) return json(res, 400, { error: "secret detected", kinds: secretCheck.kinds || [] });
|
|
2792
2857
|
if (auth?.identity && String(b.from) !== String(auth.identity.name || "")) return json(res, 403, { error: "from must match signer" });
|
|
2858
|
+
const cpg = crossProjectGuard(auth, P, b);
|
|
2859
|
+
if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
|
|
2793
2860
|
touch(b.from, undefined, undefined, undefined, auth);
|
|
2794
2861
|
// attribute the message to a project so the dashboard can show it in that project's lane.
|
|
2795
2862
|
// explicit b.project wins; else the sender's known project; else parsed from a "host:project" id.
|
package/lib/balances.mjs
CHANGED
|
@@ -152,8 +152,61 @@ export const ADAPTERS = [
|
|
|
152
152
|
return { remainingPct, plan, resetTime: head?.nextResetTime || null, detail: "" };
|
|
153
153
|
},
|
|
154
154
|
},
|
|
155
|
+
{
|
|
156
|
+
// #6131. The token plan's graded remaining-% lives ONLY behind the QwenCloud console's
|
|
157
|
+
// cookie-authenticated API (platform-api.qianwenai.com/tokenplan/personal/api/v2/usage — the
|
|
158
|
+
// console SPA calls it with a browser session; an API key gets the login page back). What the
|
|
159
|
+
// KEY can read is the plan's WALL: the inference gateway answers 429 insufficient_quota with a
|
|
160
|
+
// retry-after and the reset time the moment the 7-day plan is spent. That is exactly the state
|
|
161
|
+
// this row exists for — a seat that is about to go quiet — so it reads the wall, not the gauge,
|
|
162
|
+
// and says "unknown" rather than inventing a percentage it never measured.
|
|
163
|
+
//
|
|
164
|
+
// The probe consumes NO tokens. `messages: []` is an invalid request the model never sees, but
|
|
165
|
+
// the quota gate runs BEFORE that validation. Captured live 2026-09-03 against the spent plan:
|
|
166
|
+
// a real model + empty messages → the same 429 as a real completion; an unknown model → 404
|
|
167
|
+
// model_not_found; a bad key → 401 invalid_api_key. So the gate sits between model lookup and
|
|
168
|
+
// request validation, and an empty-messages probe is the cheapest thing that can trip it.
|
|
169
|
+
provider: "qwen", label: "Qwen", kind: "quota", match: ["qwen"], envKeys: ["QWEN_API_KEY"],
|
|
170
|
+
async fetch(key, env = {}) {
|
|
171
|
+
const base = String(env.QWEN_BASE_URL || "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1").replace(/\/+$/, "");
|
|
172
|
+
// Ask the plan which models it has rather than hard-coding an id that a rename would break
|
|
173
|
+
// (and this doubles as the auth check: a rejected key 401s here, before the probe).
|
|
174
|
+
const model = ((await getJSON(`${base}/models`, key)).data || [])[0]?.id;
|
|
175
|
+
if (!model) throw new Error("token plan lists no models");
|
|
176
|
+
const r = await fetch(`${base}/chat/completions`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { Authorization: `Bearer ${key}`, "content-type": "application/json", Accept: "application/json" },
|
|
179
|
+
body: JSON.stringify({ model, messages: [] }),
|
|
180
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
181
|
+
});
|
|
182
|
+
let body; try { body = JSON.parse(await r.text()); } catch { body = null; }
|
|
183
|
+
const err = body?.error || {};
|
|
184
|
+
if (r.status === 401 || err.code === "invalid_api_key") throw new Error("invalid API key");
|
|
185
|
+
if (r.status === 429 || err.code === "insufficient_quota") {
|
|
186
|
+
// retry-after is authoritative (seconds, exact); the message carries the same instant as
|
|
187
|
+
// "MM-DD HH:MM:SS UTC" with no year, so it is only the fallback.
|
|
188
|
+
const secs = num(r.headers.get("retry-after"));
|
|
189
|
+
const resetTime = secs > 0 ? Date.now() + secs * 1000 : qwenResetFromMessage(err.message);
|
|
190
|
+
return { remainingPct: 0, plan: "token plan", resetTime, detail: "7-day token plan exhausted" };
|
|
191
|
+
}
|
|
192
|
+
// The gate let the request through, so the plan still has room — but how much is console-only.
|
|
193
|
+
return { remainingPct: null, plan: "token plan", resetTime: null, detail: "plan active (remaining % is console-only)" };
|
|
194
|
+
},
|
|
195
|
+
},
|
|
155
196
|
];
|
|
156
197
|
|
|
198
|
+
/// "The quota will reset at 09-09 14:32:00 UTC." — Qwen omits the year, so assume the current one
|
|
199
|
+
/// and roll forward when that lands in the past (the plan resets ahead of now, never behind it).
|
|
200
|
+
export function qwenResetFromMessage(msg, now = Date.now()) {
|
|
201
|
+
const m = /reset at (\d{2})-(\d{2}) (\d{2}):(\d{2})(?::(\d{2}))? UTC/i.exec(String(msg || ""));
|
|
202
|
+
if (!m) return null;
|
|
203
|
+
const [, mo, d, h, mi, s] = m;
|
|
204
|
+
const at = (year) => Date.UTC(year, Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s || 0));
|
|
205
|
+
const year = new Date(now).getUTCFullYear();
|
|
206
|
+
const t = at(year);
|
|
207
|
+
return Number.isFinite(t) ? (t >= now ? t : at(year + 1)) : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
157
210
|
// thresholds: prepaid by currency, quota by percent-remaining. Override via config.json `lowBalance`
|
|
158
211
|
// (currency keys) and `lowQuotaPct`.
|
|
159
212
|
export const DEFAULT_LOW = { USD: 5, CNY: 35, EUR: 5 };
|
|
@@ -203,7 +256,8 @@ export async function fetchBalances(env = process.env, opts = {}) {
|
|
|
203
256
|
const envKey = a.envKeys.find((k) => env[k]);
|
|
204
257
|
if (!envKey && !a.keyless) return null; // configured but no key in env → can't query
|
|
205
258
|
const base = { provider: a.provider, label: a.label, kind: a.kind, via: envKey || "oauth" };
|
|
206
|
-
|
|
259
|
+
// `env` rides along for adapters that read more than a key (Qwen's base URL); the rest ignore it.
|
|
260
|
+
try { return { ...base, ok: true, ...(await a.fetch(envKey ? env[envKey] : undefined, env)) }; }
|
|
207
261
|
catch (e) { return { ...base, ok: false, error: String(e?.message || e) }; }
|
|
208
262
|
});
|
|
209
263
|
const rows = (await Promise.all(jobs)).filter(Boolean);
|
|
@@ -278,7 +332,7 @@ export function fmtBalance(e) {
|
|
|
278
332
|
}
|
|
279
333
|
|
|
280
334
|
function fmtReset(t) {
|
|
281
|
-
const ms =
|
|
335
|
+
const ms = Number.isFinite(t) ? t : Date.parse(t);
|
|
282
336
|
if (!ms || isNaN(ms)) return "";
|
|
283
337
|
const hrs = (ms - Date.now()) / 3600e3;
|
|
284
338
|
if (hrs < 0) return "soon";
|
package/lib/project.mjs
CHANGED
|
@@ -18,8 +18,11 @@ export function gitRoot(dir) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
// Stable project key for a working directory. RELAY_PROJECT > git-root basename > cwd basename.
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// The env comes in as a parameter (#6218): the handoff resolver must be able to resolve the cwd
|
|
22
|
+
// WITHOUT the shell's RELAY_PROJECT — a badge that lied about the cwd must not get a second
|
|
23
|
+
// voice through the fallback's own env read.
|
|
24
|
+
export function resolveProject(cwd = process.cwd(), env = process.env) {
|
|
25
|
+
if (env.RELAY_PROJECT) return env.RELAY_PROJECT.slice(0, 80);
|
|
23
26
|
const root = gitRoot(cwd);
|
|
24
27
|
// A LINKED WORKTREE must resolve to its MAIN repo's name, not its own directory name. Seat
|
|
25
28
|
// worktrees live at ~/.agent-bus/worktrees/<project>/<agent>, so the old basename rule named the
|
package/lib/turn-policy.mjs
CHANGED
|
@@ -109,6 +109,42 @@ export function reasonWithBalances(reason, rows) {
|
|
|
109
109
|
return reason === "empty-output" && quotaSpent(rows) ? "exhausted" : reason;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/// #6131: when the seat said nothing, its balance row is the only place the reset time exists —
|
|
113
|
+
/// `parseResetAt` had no output to read. The EARLIEST spent row wins, because the seat is usable
|
|
114
|
+
/// again the moment the first of its walls lifts. 0 when no spent row named a time.
|
|
115
|
+
export function quotaResetAt(rows, now = Date.now()) {
|
|
116
|
+
const times = (Array.isArray(rows) ? rows : []).flatMap((r) => {
|
|
117
|
+
if (!r || !r.ok) return [];
|
|
118
|
+
if (r.kind === "quota") return r.remainingPct != null && r.remainingPct <= 0 ? [r.resetTime] : [];
|
|
119
|
+
if (r.kind === "windows") {
|
|
120
|
+
return (r.windows || []).filter((w) => w.locked || (w.usedPct != null && w.usedPct >= 100)).map((w) => w.resetsAt);
|
|
121
|
+
}
|
|
122
|
+
return [];
|
|
123
|
+
}).map((t) => (Number.isFinite(t) ? t : Date.parse(t))).filter((t) => Number.isFinite(t) && t > now);
|
|
124
|
+
return times.length ? Math.min(...times) : 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
/// Seats that park rather than retry. A backend error is the provider having a bad minute and the
|
|
113
128
|
/// ladder is exactly right for it; a spent plan or a rejected key will not fix itself on a timer.
|
|
114
129
|
export const PARKING_REASONS = new Set(["exhausted", "auth"]);
|
|
130
|
+
|
|
131
|
+
/// #6228: the sender's home project, by the same "name suffix after the last colon" convention
|
|
132
|
+
/// every crew/orch identity is minted with (isRunnerSession in crew-runner.mjs uses the mirror
|
|
133
|
+
/// check). A session id with no colon (a bare human alias) has no home project to fence.
|
|
134
|
+
export function senderProjectOf(session) {
|
|
135
|
+
const s = String(session || "");
|
|
136
|
+
return s.includes(":") ? s.slice(s.lastIndexOf(":") + 1) : "";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// #6228: is a wake from `senderProject` allowed to reach a seat whose own project is
|
|
140
|
+
/// `seatProject`? Same project always is; a declared `trantor policy link` (the hub's own
|
|
141
|
+
/// state.orgPolicy.links, mirrored to every seat via GET /policy) opens the door for two named
|
|
142
|
+
/// projects; anything else is a cross-project wake and must be dropped, never worked — the
|
|
143
|
+
/// runner's half of the guard the hub enforces at write time.
|
|
144
|
+
export function isLinkedProject(senderProject, seatProject, links) {
|
|
145
|
+
if (!senderProject || !seatProject || senderProject === seatProject) return true;
|
|
146
|
+
return (Array.isArray(links) ? links : []).some((l) => {
|
|
147
|
+
const ps = (l?.projects || []).map((p) => String(p || "").toLowerCase());
|
|
148
|
+
return ps.includes(String(senderProject).toLowerCase()) && ps.includes(String(seatProject).toLowerCase());
|
|
149
|
+
});
|
|
150
|
+
}
|
package/mcp.mjs
CHANGED
|
@@ -380,7 +380,7 @@ server.tool("relay_peers", "Find who you can talk to: the live agent sessions on
|
|
|
380
380
|
return { content: [{ type: "text", text: lines.join("\n") || "no peers yet" }] };
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
-
server.tool("relay_send", "Send a live message to another agent session (or 'all' to broadcast). Reach the other agent YOURSELF: if you are about to ask the human to pass something along, tell the session directly instead — asking a person to carry a message between two agents is a failure, not politeness. Don't know the id? relay_peers lists them, linked projects included. Cross-project
|
|
383
|
+
server.tool("relay_send", "Send a live message to another agent session (or 'all' to broadcast). Reach the other agent YOURSELF: if you are about to ask the human to pass something along, tell the session directly instead — asking a person to carry a message between two agents is a failure, not politeness. Don't know the id? relay_peers lists them, linked projects included. Cross-project action is a breach unless the operator linked the projects (`trantor policy link <a> <b> --reason \"<why>\"`) — the hub answers 403 for a send into an unlinked project.",
|
|
384
384
|
{ to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body"),
|
|
385
385
|
wake: z.boolean().optional().describe("false = context, not a contract: the message batches into the target's next turn instead of buying it a whole CLI session. Use it for acks, FYIs and queue notes; leave it unset for anything you expect worked on.") },
|
|
386
386
|
async ({ to, text, wake }) => {
|
package/package.json
CHANGED
package/skills/crew/SKILL.md
CHANGED
|
@@ -46,6 +46,16 @@ explicit EVENT/INTERFACE CONTRACT — cross-agent bugs come from contract drift.
|
|
|
46
46
|
request natively, which is the whole reason it exists. If it is missing: `trantor app install`.
|
|
47
47
|
|
|
48
48
|
## Phase 2 — fire up the crew (with the Advisor's models)
|
|
49
|
+
**Cross-project action is a breach unless the operator linked the projects.** `trantor up` only
|
|
50
|
+
ever targets the project you are already IN — never bring up seats in, register a seat into, or
|
|
51
|
+
send a card/contract to a DIFFERENT project because an instruction reads that way ("build it where
|
|
52
|
+
the answers are stored" is not a project name). If the work genuinely belongs to another project,
|
|
53
|
+
name it and ask the operator once; do not infer it and do not route around the refusal. The hub
|
|
54
|
+
403s a cross-project `/send`, `/task`, `/task/update`, `/register` or `/invite` on its own
|
|
55
|
+
(`trantor policy link <a> <b> --reason "<why>"` is the only door), and a badged shell's `trantor up`
|
|
56
|
+
refuses the same way — this is belt and braces, not the first line of defense; get the target
|
|
57
|
+
project right before dispatching.
|
|
58
|
+
|
|
49
59
|
**Each crew card from `relay_advise` carries a `launch` spec — run it VERBATIM; never invent a
|
|
50
60
|
CLI invocation or run an agent "in a terminal" yourself.** Spawn every seat in one call:
|
|
51
61
|
`trantor up <launch> <launch> … --task <kind> --difficulty <diff>`. The live roster + the
|
|
@@ -149,6 +149,14 @@ The PRD card is done. Now one author writes the design and the same reviewers re
|
|
|
149
149
|
|
|
150
150
|
The confirmed TDD is the gate. Do not ask the operator again merely to open its build cards.
|
|
151
151
|
|
|
152
|
+
**But confirm the TARGET PROJECT before dispatching, always.** An ambiguous instruction ("build it
|
|
153
|
+
where the answers are stored") is not a project name — it does not license bringing up seats in, or
|
|
154
|
+
sending contracts into, a DIFFERENT project than the one you are reviewing. Name the project and ask
|
|
155
|
+
the operator once if the brief points anywhere but here. Cross-project action is a breach unless the
|
|
156
|
+
operator linked the projects (`trantor policy link <a> <b> --reason "<why>"`), and the hub, the
|
|
157
|
+
`trantor up` CLI, and every seat's runner all refuse it mechanically — belt and braces, not a
|
|
158
|
+
substitute for getting the target right in the first place.
|
|
159
|
+
|
|
152
160
|
1. Move the TDD card through `testing` to `done` with the tally and the decision.
|
|
153
161
|
2. `relay_advise` with the work breakdown's packages, then one `relay_task_add` per package:
|
|
154
162
|
phase `build`, the advisor's assignee and `model`, its `difficulty`, `deps` on the packages it
|