trantor 0.18.35 → 0.18.37
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 +150 -29
- package/bin/crew.sh +34 -0
- package/bin/policy.mjs +21 -1
- package/bin/slop-gate.mjs +15 -0
- package/hooks/lib/handoff.mjs +39 -5
- package/hub.mjs +86 -6
- package/lib/balances.mjs +56 -2
- package/lib/enroll.mjs +3 -2
- package/lib/project.mjs +5 -2
- package/lib/store-contract.mjs +21 -1
- package/lib/store-pg.mjs +26 -11
- package/lib/turn-policy.mjs +36 -0
- package/mcp.mjs +10 -3
- 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.37",
|
|
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
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// agent arrives it RESUMES the CLI session (native resume = full context kept) with that
|
|
10
10
|
// message as the prompt. The model just works and ends its turn; the runner does the rest.
|
|
11
11
|
import { execSync, spawnSync, spawn } from "node:child_process";
|
|
12
|
-
import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync, realpathSync } from "node:fs";
|
|
13
13
|
import { join, basename } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project.mjs";
|
|
@@ -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];
|
|
@@ -109,6 +110,26 @@ function ensureSeatWorktree(sourceDir) {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
const TURN_DIR = ensureSeatWorktree(DIR);
|
|
113
|
+
|
|
114
|
+
// #6154: opencode prints no session id on stdout, but it records every session in its own sqlite
|
|
115
|
+
// DB with the directory the session was created in. The newest row for OUR worktree is the only
|
|
116
|
+
// session a resume may pin — anything else in that DB belongs to another project on this machine,
|
|
117
|
+
// which is exactly what `run -c` used to hand us. Read-only, fail-open: no DB or no row means the
|
|
118
|
+
// next turn starts fresh, which is always safe, instead of resuming a stranger, which never is.
|
|
119
|
+
const OC_DB = join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "opencode.db");
|
|
120
|
+
function ocSid(dir) {
|
|
121
|
+
try {
|
|
122
|
+
// opencode stores the directory as IT sees its cwd, which on macOS can be the /private/var
|
|
123
|
+
// realpath of the /var/... path the runner holds — query both spellings.
|
|
124
|
+
const dirs = [dir];
|
|
125
|
+
try { const real = realpathSync(dir); if (real !== dir) dirs.push(real); } catch {}
|
|
126
|
+
const list = dirs.map((d) => `'${d.replaceAll("'", "''")}'`).join(", ");
|
|
127
|
+
const q = `SELECT id FROM session WHERE directory IN (${list}) ORDER BY time_updated DESC LIMIT 1;`;
|
|
128
|
+
const r = spawnSync("sqlite3", ["-readonly", OC_DB, q], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
|
|
129
|
+
const id = String(r.stdout || "").trim();
|
|
130
|
+
return /^ses_[A-Za-z0-9]+$/.test(id) ? id : "";
|
|
131
|
+
} catch { return ""; }
|
|
132
|
+
}
|
|
112
133
|
// RUNNER_SESSION override: an orchestrator seat (bin/orchestrate.mjs) runs the same CLI as a crew
|
|
113
134
|
// seat but must live on the bus under its own name (claude-orch:proj), or it would collide with a
|
|
114
135
|
// plain claude crew seat on the same project.
|
|
@@ -244,16 +265,25 @@ const CLI = {
|
|
|
244
265
|
// --yolo in prompt mode (prompt mode auto-approves tools), and emits session_-prefixed ids.
|
|
245
266
|
kimi: { first: `kimi{M} -p "$(cat {P})" < /dev/null`,
|
|
246
267
|
next: `kimi{M} -r {SID} -p "$(cat {P})" < /dev/null`, mflag: " --model ", sid: /To resume this session: kimi -r (\S+)/ },
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
268
|
+
// #6154: the opencode family never resumes blind. `run -c` continues the GLOBALLY last session
|
|
269
|
+
// on this machine — any project's (opencode.db showed a pr-os session interleaved between two
|
|
270
|
+
// trantor ones) — and the resumed session's stored directory becomes the Location every relative
|
|
271
|
+
// path resolves against. A seat then `cd desktop/src-tauri` inside its own worktree while
|
|
272
|
+
// opencode resolves it against a stranger's root, the bash tool reads it as external_directory
|
|
273
|
+
// and auto-rejects, and the turn dies mid-work with everything uncommitted. So: every spawn
|
|
274
|
+
// pins --dir to the seat worktree, and a resume pins -s to the session id looked up from
|
|
275
|
+
// opencode's own DB by directory — the session CREATED here (ocSid below). A missed lookup
|
|
276
|
+
// degrades to a fresh session, never to a foreign one.
|
|
277
|
+
deepseek: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
|
|
278
|
+
next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
|
|
279
|
+
opencode: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
|
|
280
|
+
next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
|
|
251
281
|
// OpenRouter rides the opencode CLI exactly like deepseek/glm, but under its OWN agent label so
|
|
252
282
|
// its bus identity is `openrouter:<project>` (RELAY_AGENT is set per-spawn) — never colliding with
|
|
253
283
|
// the glm `opencode` seat. Model ids come pre-qualified (`openrouter/<vendor>/<model>`). Sources
|
|
254
284
|
// the token-scrooge .env so an existing OPENROUTER_API_KEY authenticates with no extra wiring.
|
|
255
|
-
openrouter: { first: `opencode run{M} "$(cat {P})"`,
|
|
256
|
-
next: `opencode run -
|
|
285
|
+
openrouter: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
|
|
286
|
+
next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
|
|
257
287
|
claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
|
|
258
288
|
next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`, mflag: " --model " },
|
|
259
289
|
// DeepSeek Harness. Every turn is a FRESH session — headless has no resume yet — so the seat
|
|
@@ -275,7 +305,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
|
|
|
275
305
|
|
|
276
306
|
// RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
|
|
277
307
|
// always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
|
|
278
|
-
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.`;
|
|
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.`;
|
|
279
309
|
|
|
280
310
|
// ---- the pulse (Scape's Lloyd/Argus loop, Trantor-shaped) --------------------
|
|
281
311
|
// A message-driven seat is DEAF between messages. An orchestrator seat with a mission needs a
|
|
@@ -406,8 +436,10 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
|
|
|
406
436
|
// redelivered to. So those two reasons PARK — the queue is kept, the ladder stops, and the room is
|
|
407
437
|
// told once, with the reset time when the CLI printed one. `trantor up` (a restart) resumes.
|
|
408
438
|
let parkAnnounced = false;
|
|
409
|
-
async function parkSeat(reason, undelivered) {
|
|
410
|
-
|
|
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;
|
|
411
443
|
const when = resetAt ? new Date(resetAt).toLocaleString() : "";
|
|
412
444
|
if (!parkAnnounced) {
|
|
413
445
|
parkAnnounced = true;
|
|
@@ -427,8 +459,12 @@ async function parkSeat(reason, undelivered) {
|
|
|
427
459
|
async function balanceRows() {
|
|
428
460
|
try {
|
|
429
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");
|
|
430
466
|
return await Promise.race([
|
|
431
|
-
fetchBalances(process.env, { only: [AGENT] }),
|
|
467
|
+
fetchBalances(resolveKeys(process.env), { only: [AGENT] }),
|
|
432
468
|
new Promise((r) => setTimeout(() => r([]), 4000)),
|
|
433
469
|
]);
|
|
434
470
|
} catch { return []; }
|
|
@@ -520,9 +556,11 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
520
556
|
// exit-0 turn with real output must never be re-labelled "auth" by the #5405 escalation — the
|
|
521
557
|
// qwen specimen committed aa3c340 while its captured stream still tripped the auth regex.
|
|
522
558
|
const headBefore = gitOut(["rev-parse", "HEAD"], TURN_DIR);
|
|
523
|
-
|
|
559
|
+
// #6154: a pinned seat with no sid yet resumes as FRESH — the guard below fails open, because
|
|
560
|
+
// a resume without an id must fall back to a new session, never to `next`'s bare resume shape.
|
|
561
|
+
let cmd = (isFirst || ((cli.sid || cli.pinned) && !sid)) ? cli.first : cli.next;
|
|
524
562
|
const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
|
|
525
|
-
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid);
|
|
563
|
+
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR);
|
|
526
564
|
// PRECEDENCE, and it is easy to get backwards — this is the second time.
|
|
527
565
|
// Each file is PREPENDED, so the one prepended LAST runs FIRST, and in shell the file that runs
|
|
528
566
|
// LAST wins. To make ~/.agent-bus/.env (the CREW layer) win it must be prepended FIRST, i.e.
|
|
@@ -558,6 +596,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
558
596
|
// the window with no ERRF growth earns ONE direct stall report to the foreman, never a kill.
|
|
559
597
|
const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS || 15 * 60 * 1000);
|
|
560
598
|
const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${AGENT}-${PROJ}.json`);
|
|
599
|
+
// Written by the shell's own time box (below) and read back here — the only honest signal that
|
|
600
|
+
// the turn was CUT rather than that the CLI failed on its own. Cleared before every turn.
|
|
601
|
+
const CUTF = join(homedir(), ".agent-bus", `turncut-${AGENT}-${PROJ}`);
|
|
602
|
+
try { unlinkSync(CUTF); } catch {}
|
|
561
603
|
try {
|
|
562
604
|
writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now() }));
|
|
563
605
|
const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB],
|
|
@@ -567,8 +609,35 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
567
609
|
// Preserve the CLI's exit before waiting for the stderr process substitution. Without the
|
|
568
610
|
// explicit wait, a short failing CLI can return while its error is still in the scrub pipe;
|
|
569
611
|
// under load the classifier then reads an empty ERRF and reports the wrong failure reason.
|
|
570
|
-
|
|
571
|
-
|
|
612
|
+
// #6134-followup: the time box has to fire from INSIDE the shell, while the process tree is
|
|
613
|
+
// still standing. Killing the turn's process group from node missed a grandchild — codex runs
|
|
614
|
+
// its own commands via setsid, so `sleep 400` sat in a different group and survived
|
|
615
|
+
// process.kill(-pid). Worse, by the time node's timeout has killed bash the survivors have been
|
|
616
|
+
// reparented to init, so there is no tree left to walk and nothing to sweep.
|
|
617
|
+
//
|
|
618
|
+
// So bash boxes itself: at the deadline it walks its own descendants and kills them bottom-up.
|
|
619
|
+
// setsid changes a process's group and session but NEVER its parent, so `pgrep -P` recursion
|
|
620
|
+
// reaches exactly the children that a group signal cannot. Children first, then the parent, so
|
|
621
|
+
// nothing gets reparented mid-sweep and escapes the walk.
|
|
622
|
+
//
|
|
623
|
+
// The marker file is how node learns the turn was cut rather than merely failing: an exit status
|
|
624
|
+
// alone cannot tell "killed at the box" from "the CLI died on its own".
|
|
625
|
+
const sweep = `sweep() { local p; for p in $(pgrep -P $1 2>/dev/null); do sweep $p; done; kill -KILL $1 2>/dev/null; }`;
|
|
626
|
+
const box = TURN_MAX_MS ? `
|
|
627
|
+
${sweep}
|
|
628
|
+
( sleep ${Math.ceil(TURN_MAX_MS / 1000)}
|
|
629
|
+
kill -0 $job 2>/dev/null || exit 0
|
|
630
|
+
: > ${CUTF}
|
|
631
|
+
sweep $job
|
|
632
|
+
) & boxpid=$!` : "boxpid=";
|
|
633
|
+
const shell = `set -o pipefail
|
|
634
|
+
{ ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}) &
|
|
635
|
+
job=$!${box}
|
|
636
|
+
wait $job; turn_exit=$?
|
|
637
|
+
[ -n "$boxpid" ] && kill $boxpid 2>/dev/null
|
|
638
|
+
wait
|
|
639
|
+
exit $turn_exit`;
|
|
640
|
+
const spawnOpts = {
|
|
572
641
|
// detached: bash leads its OWN process group, so the time box can kill the CLI and everything
|
|
573
642
|
// it spawned with one signal instead of orphaning the model process behind a dead shell.
|
|
574
643
|
// stdin is /dev/null for every seat (it already was for codex/kimi/dsh via `< /dev/null`):
|
|
@@ -576,9 +645,14 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
576
645
|
// takes SIGTTIN and stops forever. Nothing here runs interactively — every CLI is in -p /
|
|
577
646
|
// exec / run mode — so closing stdin is what makes the group safe.
|
|
578
647
|
detached: true,
|
|
579
|
-
...(TURN_MAX_MS ? { timeout: TURN_MAX_MS, killSignal: "SIGKILL" } : {}),
|
|
580
648
|
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
|
|
581
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,
|
|
582
656
|
// A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
|
|
583
657
|
//
|
|
584
658
|
// The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
|
|
@@ -591,15 +665,22 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
591
665
|
// sitting there days later. To the operator that reads as "why are there two duty agents".
|
|
592
666
|
TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
|
|
593
667
|
maxBuffer: 16 * 1024 * 1024,
|
|
594
|
-
}
|
|
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);
|
|
595
674
|
try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
|
|
599
|
-
const cut = !!TURN_MAX_MS && (r.error?.code === "ETIMEDOUT"
|
|
600
|
-
if (cut
|
|
601
|
-
|
|
602
|
-
|
|
675
|
+
// The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
|
|
676
|
+
// cut, not merely failed.
|
|
677
|
+
const boxed = existsSync(CUTF);
|
|
678
|
+
const cut = !!TURN_MAX_MS && (boxed || r.error?.code === "ETIMEDOUT");
|
|
679
|
+
if (cut) {
|
|
680
|
+
// Belt and braces after the shell's descendant sweep: anything still sharing the turn's group.
|
|
681
|
+
if (r.pid) { try { process.kill(-r.pid, "SIGKILL"); } catch {} }
|
|
682
|
+
try { unlinkSync(CUTF); } catch {}
|
|
683
|
+
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`);
|
|
603
684
|
}
|
|
604
685
|
// #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
|
|
605
686
|
// wait above drains the live stderr scrubber first; this pass is defense in depth for redaction.
|
|
@@ -612,6 +693,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
612
693
|
try { ownOut = stripPromptEcho(readFileSync(ERRF, "utf8"), readPromptText(pf)); } catch { ownOut = ""; }
|
|
613
694
|
lastErrText = ownOut.slice(-4000);
|
|
614
695
|
if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
|
|
696
|
+
// #6154: the opencode family prints no sid on stdout — the id comes from opencode's own DB,
|
|
697
|
+
// keyed by the worktree the session was created in. Fail-open: nothing found leaves sid empty,
|
|
698
|
+
// and the next turn starts fresh rather than resuming whatever other project ran last.
|
|
699
|
+
if (cli.pinned) { const found = ocSid(TURN_DIR); if (found) sid = found; }
|
|
615
700
|
const realExit = r.status;
|
|
616
701
|
// A zero exit is NOT proof the turn ran: opencode prints "401 Unauthorized" / "Invalid API key"
|
|
617
702
|
// and exits 0, so a bare 0 made the runner ack "✅ done", clear the pending queue and heartbeat
|
|
@@ -651,7 +736,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
651
736
|
// #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
|
|
652
737
|
// never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
|
|
653
738
|
const tokens = parseTurnTokens(ownOut);
|
|
654
|
-
|
|
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);
|
|
655
743
|
log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
656
744
|
if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
|
|
657
745
|
// #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
|
|
@@ -756,6 +844,20 @@ function shouldWake(message) {
|
|
|
756
844
|
&& (message.text.includes(`@${AGENT}`) || message.text.toLowerCase().includes(`${AGENT}:`));
|
|
757
845
|
}
|
|
758
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
|
+
|
|
759
861
|
function askedExcerpt(message) {
|
|
760
862
|
let text = String(message?.text || "").replace(/\s+/g, " ").trim();
|
|
761
863
|
const nested = text.search(/\s+[·|]\s*asked\s*:/i);
|
|
@@ -861,7 +963,19 @@ function askedExcerpt(message) {
|
|
|
861
963
|
// deafness problem: the seat would never learn what it was told (#6134).
|
|
862
964
|
const bcast = [...rest.filter(m => !direct.includes(m) && !mentions.includes(m)), ...fyi];
|
|
863
965
|
pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
|
|
864
|
-
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));
|
|
865
979
|
if (!wake.length) { if (bcast.length) { savePending(pendingWake, pendingBcast); log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); } continue; }
|
|
866
980
|
// Queue BEFORE running the turn, and persist immediately. Everything between here and a clean
|
|
867
981
|
// exit 0 — the CLI dying, the machine losing power — now leaves a record of what this seat owes.
|
|
@@ -927,11 +1041,18 @@ function askedExcerpt(message) {
|
|
|
927
1041
|
// #6131: a silent turn on a seat whose plan reads spent is exhaustion wearing a crash's
|
|
928
1042
|
// clothes. Only that one reason is ever re-read, and only from the seat's own balance rows.
|
|
929
1043
|
let reason = classify(ec);
|
|
930
|
-
|
|
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
|
+
}
|
|
931
1052
|
savePending(pendingWake, pendingBcast);
|
|
932
1053
|
await reportFailure(ec, "message", pendingWake.length, reason);
|
|
933
1054
|
if (PARKING_REASONS.has(reason)) {
|
|
934
|
-
retryAt = await parkSeat(reason, pendingWake.length);
|
|
1055
|
+
retryAt = await parkSeat(reason, pendingWake.length, quotaReset);
|
|
935
1056
|
await notifyAssigners(assigners,
|
|
936
1057
|
`⛔ your contract is PARKED on ${SESSION} (${reason}) — not retrying · asked: "${asked}"`);
|
|
937
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)
|
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
|
@@ -10,7 +10,7 @@ import { homedir, hostname } from "node:os";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
12
12
|
import { verifyRequest, verifyEndorsement, publicView } from "./lib/identity.mjs";
|
|
13
|
-
import { DEFAULT_ORG } from "./lib/store-contract.mjs";
|
|
13
|
+
import { DEFAULT_ORG, IDENTITY_KINDS } from "./lib/store-contract.mjs";
|
|
14
14
|
import { assertNoSecrets } from "./lib/scrub.mjs";
|
|
15
15
|
import { createPersistHealth } from "./lib/persist-health.mjs";
|
|
16
16
|
|
|
@@ -220,7 +220,12 @@ function normalizeState(loaded = {}) {
|
|
|
220
220
|
// migrate old numeric form
|
|
221
221
|
s.peers[session] = typeof v === "number"
|
|
222
222
|
? { lastSeen: v, status: "", project: "" }
|
|
223
|
-
|
|
223
|
+
// #6170: `kind` must be carried across the load. This normalizer rebuilds every peer from an
|
|
224
|
+
// explicit field list, so a field missing here is dropped no matter how faithfully the store
|
|
225
|
+
// returned it — which is exactly what happened: the column was added, Postgres held the right
|
|
226
|
+
// values, and the kinds still came back empty on the first live restart. llm/model stay
|
|
227
|
+
// out on purpose: those ARE in-memory presence, re-supplied by the next heartbeat.
|
|
228
|
+
: { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "", pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "", hookVersion: v.hookVersion || "", kind: v.kind || "", deliveredUpTo: v.deliveredUpTo || v.delivered_up_to || 0, _on: v._on === true || v.online === true };
|
|
224
229
|
}
|
|
225
230
|
return s;
|
|
226
231
|
}
|
|
@@ -1096,6 +1101,61 @@ function projectFromRequest(P, q, b) {
|
|
|
1096
1101
|
}
|
|
1097
1102
|
return canon(String(b?.project || q?.project || "").slice(0, 80));
|
|
1098
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
|
+
}
|
|
1099
1159
|
// Is a stored baton claim still worth honouring? The claim names the instance it spared
|
|
1100
1160
|
// (`exceptInstanceId`), so we defer to THAT instance only while it is still being seen. A claimant
|
|
1101
1161
|
// that died stops muzzling its twins; one that comes back starts again. Records claimed before
|
|
@@ -1447,6 +1507,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1447
1507
|
const raw = req._rawBody || "";
|
|
1448
1508
|
const verified = verifyRequest({ headers: req.headers, method: req.method, path: authPath(u), body: raw });
|
|
1449
1509
|
if (!verified.ok) return json(res, 401, { error: verified.reason || "bad signature" });
|
|
1510
|
+
const requestedKind = String(b0.kind || "agent").slice(0, 40);
|
|
1511
|
+
if (!IDENTITY_KINDS.includes(requestedKind)) {
|
|
1512
|
+
return json(res, 400, { error: `kind must be one of: ${IDENTITY_KINDS.join(", ")}`, allowedKinds: IDENTITY_KINDS });
|
|
1513
|
+
}
|
|
1450
1514
|
const existing = findIdentity(verified.pubkey);
|
|
1451
1515
|
if (existing) return json(res, 200, { ok: true, identity: publicView(existing), scopes: existing.scopes || [] });
|
|
1452
1516
|
let enrolledBy = "";
|
|
@@ -1482,7 +1546,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1482
1546
|
}
|
|
1483
1547
|
const identity = {
|
|
1484
1548
|
name: String(b0.name || "").slice(0, 120) || verified.pubkey.slice(0, 16),
|
|
1485
|
-
kind:
|
|
1549
|
+
kind: requestedKind,
|
|
1486
1550
|
pubkey: verified.pubkey,
|
|
1487
1551
|
createdAt: now(),
|
|
1488
1552
|
enrolledBy,
|
|
@@ -1501,14 +1565,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
1501
1565
|
const az = authorize(auth, req.method, P, "*");
|
|
1502
1566
|
if (!az.ok) return json(res, az.code || 403, { error: az.error || "forbidden" });
|
|
1503
1567
|
const bi = await body(req);
|
|
1568
|
+
const invitedKind = String(bi.kind || "agent").slice(0, 40);
|
|
1569
|
+
if (!IDENTITY_KINDS.includes(invitedKind)) {
|
|
1570
|
+
return json(res, 400, { error: `kind must be one of: ${IDENTITY_KINDS.join(", ")}`, allowedKinds: IDENTITY_KINDS });
|
|
1571
|
+
}
|
|
1504
1572
|
const scopes = (Array.isArray(bi.scopes) ? bi.scopes : []).map(cleanScope).filter(Boolean).slice(0, 20);
|
|
1505
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 });
|
|
1506
1576
|
// Honour the requested TTL. A 60s FLOOR here silently inflated `ttlSec: 1` to a minute, so a
|
|
1507
1577
|
// token the caller asked to expire in a second stayed valid — and an expired-token test passed
|
|
1508
1578
|
// a live token straight through /enroll. Cap the ceiling, never the floor.
|
|
1509
1579
|
const ttlSec = Math.min(Math.max(Number(bi.ttlSec) || 86400, 1), 30 * 86400);
|
|
1510
1580
|
const token = randomBytes(24).toString("hex");
|
|
1511
|
-
state.inviteTokens[token] = { scopes, expiresAt: now() + ttlSec * 1000, used: false,
|
|
1581
|
+
state.inviteTokens[token] = { scopes, kind: invitedKind, expiresAt: now() + ttlSec * 1000, used: false,
|
|
1512
1582
|
createdBy: auth.identity?.pubkey || "", createdAt: now() };
|
|
1513
1583
|
dirty = true;
|
|
1514
1584
|
return json(res, 200, { ok: true, token, scopes, expiresAt: state.inviteTokens[token].expiresAt });
|
|
@@ -1517,7 +1587,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1517
1587
|
const authz = authorize(auth, req.method, P, projectFromRequest(P, q, b0));
|
|
1518
1588
|
if (!authz.ok) return json(res, authz.code || 403, { error: authz.error || "forbidden" });
|
|
1519
1589
|
if (req.method === "POST" && P === "/register") {
|
|
1520
|
-
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);
|
|
1521
1594
|
// WHO is this, really: the LLM brand + the exact model currently loaded. In-memory like the
|
|
1522
1595
|
// rest of presence — the next heartbeat re-supplies it after a restart.
|
|
1523
1596
|
const pr = state.peers[b.session];
|
|
@@ -1919,7 +1992,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1919
1992
|
}
|
|
1920
1993
|
// --- Kanban tasks ---
|
|
1921
1994
|
if (req.method === "POST" && P === "/task") { // create a card
|
|
1922
|
-
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);
|
|
1923
1999
|
if (b.title !== undefined) b.title = stripNulText(b.title);
|
|
1924
2000
|
if (b.note !== undefined) b.note = stripNulText(b.note);
|
|
1925
2001
|
const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
|
|
@@ -2075,6 +2151,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2075
2151
|
if (req.method === "POST" && P === "/task/update") { // move/edit a card
|
|
2076
2152
|
const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
|
|
2077
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 });
|
|
2078
2156
|
if (b.title !== undefined) b.title = stripNulText(b.title);
|
|
2079
2157
|
if (b.note !== undefined) b.note = stripNulText(b.note);
|
|
2080
2158
|
// Board integrity (#5406): a card can never change hands silently. The assignee is frozen once
|
|
@@ -2777,6 +2855,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2777
2855
|
const secretCheck = assertNoSecrets(text);
|
|
2778
2856
|
if (!secretCheck.ok) return json(res, 400, { error: "secret detected", kinds: secretCheck.kinds || [] });
|
|
2779
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 });
|
|
2780
2860
|
touch(b.from, undefined, undefined, undefined, auth);
|
|
2781
2861
|
// attribute the message to a project so the dashboard can show it in that project's lane.
|
|
2782
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/enroll.mjs
CHANGED
|
@@ -45,9 +45,10 @@ export async function ensureEnrolled(hubUrl, identity, project, { timeoutMs = 40
|
|
|
45
45
|
if (!owner?.privkey) return { ok: false, reason: "no-owner-key" };
|
|
46
46
|
|
|
47
47
|
try {
|
|
48
|
+
const identityKind = kind || identity.kind || "agent";
|
|
48
49
|
const inv = await sfetch(`${hubUrl}/invite`, {
|
|
49
50
|
method: "POST", headers: { "content-type": "application/json" },
|
|
50
|
-
body: JSON.stringify({ scopes: [{ project, role: "write" }], ttlSec: 300 }),
|
|
51
|
+
body: JSON.stringify({ scopes: [{ project, role: "write" }], ttlSec: 300, kind: identityKind }),
|
|
51
52
|
signal: AbortSignal.timeout(timeoutMs),
|
|
52
53
|
}, owner);
|
|
53
54
|
if (!inv.ok) return { ok: false, reason: `invite-${inv.status}` };
|
|
@@ -55,7 +56,7 @@ export async function ensureEnrolled(hubUrl, identity, project, { timeoutMs = 40
|
|
|
55
56
|
|
|
56
57
|
const en = await sfetch(`${hubUrl}/enroll`, {
|
|
57
58
|
method: "POST", headers: { "content-type": "application/json" },
|
|
58
|
-
body: JSON.stringify({ name: identity.name, kind:
|
|
59
|
+
body: JSON.stringify({ name: identity.name, kind: identityKind, token }),
|
|
59
60
|
signal: AbortSignal.timeout(timeoutMs),
|
|
60
61
|
}, identity);
|
|
61
62
|
return en.ok ? { ok: true, reason: "enrolled" } : { ok: false, reason: `enroll-${en.status}` };
|
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/store-contract.mjs
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
// an empty schema is free; adding it after 1,542 cards have migrated is surgery on live data.
|
|
21
21
|
// ---------------------------------------------------------------------------------------------
|
|
22
22
|
export const SCHEMA_VERSION = 1;
|
|
23
|
+
export const IDENTITY_KINDS = Object.freeze(["human", "agent", "tool"]);
|
|
24
|
+
|
|
25
|
+
const IDENTITY_KINDS_SQL = IDENTITY_KINDS.map(kind => `'${kind}'`).join(",");
|
|
23
26
|
|
|
24
27
|
export const SCHEMA_SQL = `
|
|
25
28
|
CREATE TABLE IF NOT EXISTS orgs (
|
|
@@ -41,13 +44,19 @@ CREATE TABLE IF NOT EXISTS identities (
|
|
|
41
44
|
pubkey TEXT PRIMARY KEY,
|
|
42
45
|
org_id TEXT REFERENCES orgs(id) ON DELETE CASCADE,
|
|
43
46
|
name TEXT NOT NULL,
|
|
44
|
-
kind TEXT NOT NULL CHECK (kind IN (
|
|
47
|
+
kind TEXT NOT NULL CHECK (kind IN (${IDENTITY_KINDS_SQL})),
|
|
45
48
|
scopes JSONB NOT NULL DEFAULT '{}'::jsonb, -- { "<project>": "owner"|"write"|"read" }
|
|
46
49
|
enrolled_by TEXT,
|
|
47
50
|
created_at BIGINT NOT NULL,
|
|
48
51
|
revoked_at BIGINT -- set, never deleted: revocation is audit
|
|
49
52
|
);
|
|
50
53
|
|
|
54
|
+
-- 2026-09-03: installs created under CHECK (kind IN ('human','agent')) widen on boot; the genesis
|
|
55
|
+
-- identity enrols as 'tool' (#6068) and the narrower check poisoned every persist delta for 13 min.
|
|
56
|
+
ALTER TABLE identities DROP CONSTRAINT IF EXISTS identities_kind_check;
|
|
57
|
+
ALTER TABLE identities ADD CONSTRAINT identities_kind_check CHECK (kind IN (${IDENTITY_KINDS_SQL}));
|
|
58
|
+
|
|
59
|
+
|
|
51
60
|
-- THE LOG. Append-only, never updated, never deleted except by retention. Everything else derives.
|
|
52
61
|
CREATE TABLE IF NOT EXISTS events (
|
|
53
62
|
id BIGSERIAL PRIMARY KEY,
|
|
@@ -120,8 +129,19 @@ CREATE TABLE IF NOT EXISTS peers (
|
|
|
120
129
|
last_seen BIGINT,
|
|
121
130
|
online BOOLEAN DEFAULT FALSE,
|
|
122
131
|
delivered_up_to BIGINT DEFAULT 0,
|
|
132
|
+
kind TEXT, -- #6170: WHAT this session is — 'agent' (crew seat),
|
|
133
|
+
-- 'orch', 'genesis', 'tool'. The overseer's crew
|
|
134
|
+
-- exemption reads it (#6075/#6148), so when a restart
|
|
135
|
+
-- forgot it the hub warned about its own crew.
|
|
123
136
|
PRIMARY KEY (org_id, session)
|
|
124
137
|
);
|
|
138
|
+
-- additive migration for hubs whose peers table predates the kind column (#6170)
|
|
139
|
+
ALTER TABLE peers ADD COLUMN IF NOT EXISTS kind TEXT;
|
|
140
|
+
-- DELIBERATELY no CHECK on peers.kind, unlike identities.kind above. The hub accepts whatever a
|
|
141
|
+
-- client stamps (hub.mjs /register takes any string up to 40 chars) and the vocabulary grows with
|
|
142
|
+
-- the product — 'genesis' arrived in #6068, 'orch' in #6075. #6169 is the cost of getting this
|
|
143
|
+
-- wrong in the other direction: a CHECK narrower than the values in flight poisoned every persist
|
|
144
|
+
-- delta for 13 minutes. A column that stores what it is given cannot fail that way.
|
|
125
145
|
|
|
126
146
|
-- The fields that currently ride in-memory and are LOST on restart. This is the debt being paid.
|
|
127
147
|
CREATE TABLE IF NOT EXISTS kv (
|
package/lib/store-pg.mjs
CHANGED
|
@@ -117,6 +117,7 @@ function peerFromRow(row) {
|
|
|
117
117
|
lastSeen: row.last_seen == null ? 0 : Number(row.last_seen),
|
|
118
118
|
online: !!row.online,
|
|
119
119
|
deliveredUpTo: row.delivered_up_to == null ? 0 : Number(row.delivered_up_to),
|
|
120
|
+
kind: row.kind || "", // #6170: survives the restart that used to forget who was crew
|
|
120
121
|
};
|
|
121
122
|
}
|
|
122
123
|
|
|
@@ -332,8 +333,8 @@ export class PgStore {
|
|
|
332
333
|
async touchPeer(orgId, session, patch = {}) {
|
|
333
334
|
if (!session || session === "all") return;
|
|
334
335
|
await this.pool.query(
|
|
335
|
-
`INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to)
|
|
336
|
-
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
336
|
+
`INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to, kind)
|
|
337
|
+
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
337
338
|
ON CONFLICT(org_id, session) DO UPDATE SET
|
|
338
339
|
pubkey=COALESCE(EXCLUDED.pubkey, peers.pubkey),
|
|
339
340
|
project=COALESCE(NULLIF(EXCLUDED.project,''), peers.project),
|
|
@@ -341,11 +342,17 @@ export class PgStore {
|
|
|
341
342
|
hook_version=COALESCE(NULLIF(EXCLUDED.hook_version,''), peers.hook_version),
|
|
342
343
|
last_seen=EXCLUDED.last_seen,
|
|
343
344
|
online=EXCLUDED.online,
|
|
344
|
-
delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to)
|
|
345
|
+
delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to),
|
|
346
|
+
-- #6170: a KINDLESS beat must never demote a known peer. Most heartbeats carry no kind
|
|
347
|
+
-- (the MCP one only learns it under TRANTOR_ORCH), and plain last_seen refreshes are the
|
|
348
|
+
-- majority of writes here — overwriting on every one is how the orchestrator kept
|
|
349
|
+
-- reverting to a nameless agent between registrations.
|
|
350
|
+
kind=COALESCE(NULLIF(EXCLUDED.kind,''), peers.kind)`,
|
|
345
351
|
[
|
|
346
352
|
session, orgId, patch.pubkey || null, patch.project || "", patch.status ?? null,
|
|
347
353
|
patch.hookVersion || patch.hook_version || "", ms(patch.lastSeen || patch.last_seen),
|
|
348
354
|
patch.online ?? true, Number(patch.deliveredUpTo || patch.delivered_up_to || 0),
|
|
355
|
+
patch.kind || "",
|
|
349
356
|
],
|
|
350
357
|
);
|
|
351
358
|
}
|
|
@@ -474,11 +481,15 @@ export class PgStore {
|
|
|
474
481
|
);
|
|
475
482
|
for (const [session, p] of Object.entries(state.peers || {})) {
|
|
476
483
|
await c.query(
|
|
477
|
-
`INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to)
|
|
478
|
-
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
484
|
+
`INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to, kind)
|
|
485
|
+
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
479
486
|
ON CONFLICT(org_id, session) DO UPDATE SET pubkey=EXCLUDED.pubkey, project=EXCLUDED.project, status=EXCLUDED.status,
|
|
480
|
-
hook_version=EXCLUDED.hook_version, last_seen=EXCLUDED.last_seen, online=EXCLUDED.online, delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to)
|
|
481
|
-
|
|
487
|
+
hook_version=EXCLUDED.hook_version, last_seen=EXCLUDED.last_seen, online=EXCLUDED.online, delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to),
|
|
488
|
+
-- #6170: same non-demoting rule as touchPeer. A snapshot is written from MEMORY, and
|
|
489
|
+
-- memory is exactly where the kind was being lost, so a blank in the snapshot means
|
|
490
|
+
-- "not known right now", never "this peer is nothing".
|
|
491
|
+
kind=COALESCE(NULLIF(EXCLUDED.kind,''), peers.kind)`,
|
|
492
|
+
[session, orgId, p.pubkey || "", p.project || "", p.status || "", p.hookVersion || "", Number(p.lastSeen || 0), p._on === true || p.online === true, Number(p.deliveredUpTo || 0), p.kind || ""],
|
|
482
493
|
);
|
|
483
494
|
}
|
|
484
495
|
await c.query(
|
|
@@ -598,11 +609,15 @@ export class PgStore {
|
|
|
598
609
|
if (messages.deletes.length) await c.query("DELETE FROM messages WHERE org_id=$1 AND id = ANY($2::bigint[])", [orgId, messages.deletes]);
|
|
599
610
|
for (const p of peers.upserts) {
|
|
600
611
|
await c.query(
|
|
601
|
-
`INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to)
|
|
602
|
-
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
612
|
+
`INSERT INTO peers(session, org_id, pubkey, project, status, hook_version, last_seen, online, delivered_up_to, kind)
|
|
613
|
+
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
603
614
|
ON CONFLICT(org_id, session) DO UPDATE SET pubkey=EXCLUDED.pubkey, project=EXCLUDED.project, status=EXCLUDED.status,
|
|
604
|
-
hook_version=EXCLUDED.hook_version, last_seen=EXCLUDED.last_seen, online=EXCLUDED.online, delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to)
|
|
605
|
-
|
|
615
|
+
hook_version=EXCLUDED.hook_version, last_seen=EXCLUDED.last_seen, online=EXCLUDED.online, delivered_up_to=GREATEST(peers.delivered_up_to, EXCLUDED.delivered_up_to),
|
|
616
|
+
-- #6170: THIS is the path a running hub actually persists through — saveDelta on the
|
|
617
|
+
-- persist tick, not touchPeer or saveSnapshot. Adding the column to the other two and
|
|
618
|
+
-- not this one is why the first live restart still came back with empty kinds.
|
|
619
|
+
kind=COALESCE(NULLIF(EXCLUDED.kind,''), peers.kind)`,
|
|
620
|
+
[p.session, orgId, p.pubkey || "", p.project || "", p.status || "", p.hookVersion || "", Number(p.lastSeen || 0), p._on === true || p.online === true, Number(p.deliveredUpTo || 0), p.kind || ""],
|
|
606
621
|
);
|
|
607
622
|
}
|
|
608
623
|
if (peers.deletes.length) await c.query("DELETE FROM peers WHERE org_id=$1 AND session = ANY($2::text[])", [orgId, peers.deletes]);
|
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 }) => {
|
|
@@ -480,8 +480,15 @@ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
|
480
480
|
const nonProjectReason = nonSeatReason(projectDir);
|
|
481
481
|
const isHomeDirSession = !!nonProjectReason;
|
|
482
482
|
|
|
483
|
+
// #6170: WHAT this session is, when it can know. sessionstart stamps kind "orch" on the
|
|
484
|
+
// orchestrator pane, but that runs once — every MCP beat afterwards was kindless, and the hub's
|
|
485
|
+
// crew exemption reads the peer row's kind, so the orchestrator kept being demoted by its own
|
|
486
|
+
// heartbeat and then warned about as an intruder on its own project. Same test sessionstart uses:
|
|
487
|
+
// TRANTOR_ORCH names the project this pane orchestrates.
|
|
488
|
+
const KIND = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === PROJECT ? { kind: "orch" } : {};
|
|
489
|
+
|
|
483
490
|
if (!isHomeDirSession) {
|
|
484
|
-
await api("POST", "/register", { session: SESSION, project: PROJECT, status: `active in ${PROJECT}`, hookVersion: MCP_VERSION })
|
|
491
|
+
await api("POST", "/register", { session: SESSION, project: PROJECT, status: `active in ${PROJECT}`, hookVersion: MCP_VERSION, ...KIND })
|
|
485
492
|
.catch((err) => { process.stderr.write(`[trantor-mcp] initial register failed: ${err?.message || err}\n`); });
|
|
486
493
|
|
|
487
494
|
// Heartbeat — keep this session's presence fresh for as long as the MCP process lives.
|
|
@@ -493,7 +500,7 @@ if (!isHomeDirSession) {
|
|
|
493
500
|
// hub refreshes lastSeen but preserves the session's meaningful status. setInterval pauses during
|
|
494
501
|
// sleep and fires on wake, so presence self-heals within one interval; .unref() lets the process
|
|
495
502
|
// still exit cleanly when the agent closes the stdio transport (no phantom peers).
|
|
496
|
-
setInterval(() => { api("POST", "/register", { session: SESSION, project: PROJECT, hookVersion: MCP_VERSION }).catch(() => {}); }, HEARTBEAT_MS).unref?.();
|
|
503
|
+
setInterval(() => { api("POST", "/register", { session: SESSION, project: PROJECT, hookVersion: MCP_VERSION, ...KIND }).catch(() => {}); }, HEARTBEAT_MS).unref?.();
|
|
497
504
|
} else {
|
|
498
505
|
process.stderr.write(`[trantor-mcp] ${nonProjectReason} — not auto-registering on the bus (set RELAY_SESSION or RELAY_PROJECT to opt in)\n`);
|
|
499
506
|
}
|
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
|