trantor 0.18.34 → 0.18.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/crew-runner.mjs +209 -22
- package/bin/crew.sh +6 -2
- package/bin/seat-why.mjs +2 -1
- package/hub.mjs +22 -5
- package/lib/enroll.mjs +3 -2
- package/lib/seat-why.mjs +27 -1
- package/lib/store-contract.mjs +21 -1
- package/lib/store-pg.mjs +26 -11
- package/lib/turn-policy.mjs +114 -0
- package/mcp.mjs +59 -8
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.36",
|
|
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/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";
|
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
readPromptText, stripPromptEcho,
|
|
24
24
|
} from "../lib/classify-failure.mjs";
|
|
25
25
|
import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
|
|
26
|
+
import {
|
|
27
|
+
cardRef, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, PARKING_REASONS,
|
|
28
|
+
} from "../lib/turn-policy.mjs";
|
|
26
29
|
|
|
27
30
|
const AGENT = process.argv[2];
|
|
28
31
|
const DIR = process.argv[3] || process.cwd();
|
|
@@ -106,6 +109,26 @@ function ensureSeatWorktree(sourceDir) {
|
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
const TURN_DIR = ensureSeatWorktree(DIR);
|
|
112
|
+
|
|
113
|
+
// #6154: opencode prints no session id on stdout, but it records every session in its own sqlite
|
|
114
|
+
// DB with the directory the session was created in. The newest row for OUR worktree is the only
|
|
115
|
+
// session a resume may pin — anything else in that DB belongs to another project on this machine,
|
|
116
|
+
// which is exactly what `run -c` used to hand us. Read-only, fail-open: no DB or no row means the
|
|
117
|
+
// next turn starts fresh, which is always safe, instead of resuming a stranger, which never is.
|
|
118
|
+
const OC_DB = join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "opencode.db");
|
|
119
|
+
function ocSid(dir) {
|
|
120
|
+
try {
|
|
121
|
+
// opencode stores the directory as IT sees its cwd, which on macOS can be the /private/var
|
|
122
|
+
// realpath of the /var/... path the runner holds — query both spellings.
|
|
123
|
+
const dirs = [dir];
|
|
124
|
+
try { const real = realpathSync(dir); if (real !== dir) dirs.push(real); } catch {}
|
|
125
|
+
const list = dirs.map((d) => `'${d.replaceAll("'", "''")}'`).join(", ");
|
|
126
|
+
const q = `SELECT id FROM session WHERE directory IN (${list}) ORDER BY time_updated DESC LIMIT 1;`;
|
|
127
|
+
const r = spawnSync("sqlite3", ["-readonly", OC_DB, q], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
|
|
128
|
+
const id = String(r.stdout || "").trim();
|
|
129
|
+
return /^ses_[A-Za-z0-9]+$/.test(id) ? id : "";
|
|
130
|
+
} catch { return ""; }
|
|
131
|
+
}
|
|
109
132
|
// RUNNER_SESSION override: an orchestrator seat (bin/orchestrate.mjs) runs the same CLI as a crew
|
|
110
133
|
// seat but must live on the bus under its own name (claude-orch:proj), or it would collide with a
|
|
111
134
|
// plain claude crew seat on the same project.
|
|
@@ -241,16 +264,25 @@ const CLI = {
|
|
|
241
264
|
// --yolo in prompt mode (prompt mode auto-approves tools), and emits session_-prefixed ids.
|
|
242
265
|
kimi: { first: `kimi{M} -p "$(cat {P})" < /dev/null`,
|
|
243
266
|
next: `kimi{M} -r {SID} -p "$(cat {P})" < /dev/null`, mflag: " --model ", sid: /To resume this session: kimi -r (\S+)/ },
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
267
|
+
// #6154: the opencode family never resumes blind. `run -c` continues the GLOBALLY last session
|
|
268
|
+
// on this machine — any project's (opencode.db showed a pr-os session interleaved between two
|
|
269
|
+
// trantor ones) — and the resumed session's stored directory becomes the Location every relative
|
|
270
|
+
// path resolves against. A seat then `cd desktop/src-tauri` inside its own worktree while
|
|
271
|
+
// opencode resolves it against a stranger's root, the bash tool reads it as external_directory
|
|
272
|
+
// and auto-rejects, and the turn dies mid-work with everything uncommitted. So: every spawn
|
|
273
|
+
// pins --dir to the seat worktree, and a resume pins -s to the session id looked up from
|
|
274
|
+
// opencode's own DB by directory — the session CREATED here (ocSid below). A missed lookup
|
|
275
|
+
// degrades to a fresh session, never to a foreign one.
|
|
276
|
+
deepseek: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
|
|
277
|
+
next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
|
|
278
|
+
opencode: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
|
|
279
|
+
next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
|
|
248
280
|
// OpenRouter rides the opencode CLI exactly like deepseek/glm, but under its OWN agent label so
|
|
249
281
|
// its bus identity is `openrouter:<project>` (RELAY_AGENT is set per-spawn) — never colliding with
|
|
250
282
|
// the glm `opencode` seat. Model ids come pre-qualified (`openrouter/<vendor>/<model>`). Sources
|
|
251
283
|
// the token-scrooge .env so an existing OPENROUTER_API_KEY authenticates with no extra wiring.
|
|
252
|
-
openrouter: { first: `opencode run{M} "$(cat {P})"`,
|
|
253
|
-
next: `opencode run -
|
|
284
|
+
openrouter: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
|
|
285
|
+
next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
|
|
254
286
|
claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
|
|
255
287
|
next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`, mflag: " --model " },
|
|
256
288
|
// DeepSeek Harness. Every turn is a FRESH session — headless has no resume yet — so the seat
|
|
@@ -272,7 +304,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
|
|
|
272
304
|
|
|
273
305
|
// RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
|
|
274
306
|
// always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
|
|
275
|
-
const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a 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.`;
|
|
276
308
|
|
|
277
309
|
// ---- the pulse (Scape's Lloyd/Argus loop, Trantor-shaped) --------------------
|
|
278
310
|
// A message-driven seat is DEAF between messages. An orchestrator seat with a mission needs a
|
|
@@ -353,9 +385,9 @@ function classify(exit) {
|
|
|
353
385
|
return reason;
|
|
354
386
|
}
|
|
355
387
|
|
|
356
|
-
async function reportFailure(exit, trigger, undelivered = 0) {
|
|
388
|
+
async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = "") {
|
|
357
389
|
consecFails++;
|
|
358
|
-
const reason = classify(exit);
|
|
390
|
+
const reason = reasonOverride || classify(exit);
|
|
359
391
|
const down = consecFails >= 2;
|
|
360
392
|
const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
|
|
361
393
|
await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
@@ -394,6 +426,41 @@ async function reportFailure(exit, trigger, undelivered = 0) {
|
|
|
394
426
|
}
|
|
395
427
|
cmuxStatus(down ? "down" : "error", "#ef6a6a", "alert", { alert: true, priority: 90 }); herdrAgent("blocked"); cmuxLog(`turn failed: ${reason} (exit ${exit})`, "error");
|
|
396
428
|
log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
|
|
429
|
+
return reason;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---- a dead seat is not retried (#6134) -------------------------------------------------------
|
|
433
|
+
// The redelivery ladder assumes the next attempt might work. Against a spent plan or a rejected
|
|
434
|
+
// key it never will, and the cost is real: codex burned 60 turns on 09-02 doing nothing but being
|
|
435
|
+
// redelivered to. So those two reasons PARK — the queue is kept, the ladder stops, and the room is
|
|
436
|
+
// told once, with the reset time when the CLI printed one. `trantor up` (a restart) resumes.
|
|
437
|
+
let parkAnnounced = false;
|
|
438
|
+
async function parkSeat(reason, undelivered) {
|
|
439
|
+
const resetAt = parseResetAt(lastErrText);
|
|
440
|
+
const when = resetAt ? new Date(resetAt).toLocaleString() : "";
|
|
441
|
+
if (!parkAnnounced) {
|
|
442
|
+
parkAnnounced = true;
|
|
443
|
+
const text = redactKeys(`⛔ ${SESSION} PARKED (${reason}) — holding ${undelivered} message(s), redelivery stopped ${when ? `until ${when}` : `until \`trantor up ${AGENT}\``}`);
|
|
444
|
+
await api("/send", { from: SESSION, to: "all", text, project: PROJ, kind: "status" }).catch(() => {});
|
|
445
|
+
const orch = `${hostId()}:${PROJ}`;
|
|
446
|
+
if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
|
|
447
|
+
}
|
|
448
|
+
log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
|
|
449
|
+
// No reset time means no timer can clear it: hold until the operator restarts the seat.
|
|
450
|
+
return resetAt || Number.MAX_SAFE_INTEGER;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// The seat's own balance rows, for the #6131 read: a stalled turn that printed nothing on a seat
|
|
454
|
+
// whose plan is spent is exhaustion, not a crash. Bounded and best-effort — a slow provider API
|
|
455
|
+
// must never hold up the failure path, and an unreachable one just leaves the reason as it was.
|
|
456
|
+
async function balanceRows() {
|
|
457
|
+
try {
|
|
458
|
+
const { fetchBalances } = await import("../lib/balances.mjs");
|
|
459
|
+
return await Promise.race([
|
|
460
|
+
fetchBalances(process.env, { only: [AGENT] }),
|
|
461
|
+
new Promise((r) => setTimeout(() => r([]), 4000)),
|
|
462
|
+
]);
|
|
463
|
+
} catch { return []; }
|
|
397
464
|
}
|
|
398
465
|
|
|
399
466
|
// ---- activity truth (#5965): the RUNNER is the source for this seat ----------------
|
|
@@ -444,17 +511,33 @@ async function notifyAssigners(pairs, text) {
|
|
|
444
511
|
async function reportHealthy() {
|
|
445
512
|
if (consecFails === 0) return; // already healthy — don't spam
|
|
446
513
|
consecFails = 0;
|
|
447
|
-
// Recovery is a change too, so the next failure is news again.
|
|
514
|
+
// Recovery is a change too, so the next failure is news again — a park included.
|
|
448
515
|
announced = "";
|
|
516
|
+
parkAnnounced = false;
|
|
449
517
|
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
450
518
|
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
|
|
451
519
|
cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
|
|
452
520
|
}
|
|
453
521
|
|
|
522
|
+
// ---- the time box (#6134) --------------------------------------------------------------------
|
|
523
|
+
// A turn with no ceiling is how a seat spends an afternoon on one card: the 09-02 baseline was 151
|
|
524
|
+
// turns and ~16 agentic hours across the fleet. TRANTOR_TURN_MAX_MS ends the CLI's process group
|
|
525
|
+
// at the box and runs ONE follow-up turn in the SAME session — "commit what is done, move the
|
|
526
|
+
// card, report in one line" — so a cut turn lands its work instead of losing it.
|
|
527
|
+
const TURN_MAX_MS = Math.max(0, Number(process.env.TRANTOR_TURN_MAX_MS || 20 * 60 * 1000));
|
|
528
|
+
const TIME_BOX_PROMPT = "your previous turn was cut at the time box; commit what is done, move the card with a note, report in one line";
|
|
529
|
+
let inFollowUp = false;
|
|
530
|
+
// The card the CURRENT CLI session belongs to (#6134). 0 = the kickoff session, which belongs to
|
|
531
|
+
// no card, so the first contract that names one starts a session of its own.
|
|
532
|
+
let sessionCard = 0;
|
|
533
|
+
|
|
454
534
|
let sid = "";
|
|
455
535
|
async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
456
536
|
TURN++; banner(trigger);
|
|
457
537
|
const t0 = Date.now();
|
|
538
|
+
// A fresh session must not resume the old one's id: `first` is chosen by isFirst OR a missing
|
|
539
|
+
// sid, so a stale sid would quietly resume the session this turn exists to leave behind.
|
|
540
|
+
if (isFirst) sid = "";
|
|
458
541
|
// #5965 — TURN START. The hub peer row is where the app reads activity from, and the runner is
|
|
459
542
|
// the only one who knows a turn is starting, so say so before the CLI spawn (awaited: the spawn
|
|
460
543
|
// below blocks the loop, an unawaited fetch would not leave the machine until the turn ended).
|
|
@@ -466,9 +549,11 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
466
549
|
// exit-0 turn with real output must never be re-labelled "auth" by the #5405 escalation — the
|
|
467
550
|
// qwen specimen committed aa3c340 while its captured stream still tripped the auth regex.
|
|
468
551
|
const headBefore = gitOut(["rev-parse", "HEAD"], TURN_DIR);
|
|
469
|
-
|
|
552
|
+
// #6154: a pinned seat with no sid yet resumes as FRESH — the guard below fails open, because
|
|
553
|
+
// a resume without an id must fall back to a new session, never to `next`'s bare resume shape.
|
|
554
|
+
let cmd = (isFirst || ((cli.sid || cli.pinned) && !sid)) ? cli.first : cli.next;
|
|
470
555
|
const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
|
|
471
|
-
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid);
|
|
556
|
+
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR);
|
|
472
557
|
// PRECEDENCE, and it is easy to get backwards — this is the second time.
|
|
473
558
|
// Each file is PREPENDED, so the one prepended LAST runs FIRST, and in shell the file that runs
|
|
474
559
|
// LAST wins. To make ~/.agent-bus/.env (the CREW layer) win it must be prepended FIRST, i.e.
|
|
@@ -504,6 +589,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
504
589
|
// the window with no ERRF growth earns ONE direct stall report to the foreman, never a kill.
|
|
505
590
|
const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS || 15 * 60 * 1000);
|
|
506
591
|
const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${AGENT}-${PROJ}.json`);
|
|
592
|
+
// Written by the shell's own time box (below) and read back here — the only honest signal that
|
|
593
|
+
// the turn was CUT rather than that the CLI failed on its own. Cleared before every turn.
|
|
594
|
+
const CUTF = join(homedir(), ".agent-bus", `turncut-${AGENT}-${PROJ}`);
|
|
595
|
+
try { unlinkSync(CUTF); } catch {}
|
|
507
596
|
try {
|
|
508
597
|
writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now() }));
|
|
509
598
|
const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB],
|
|
@@ -513,9 +602,47 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
513
602
|
// Preserve the CLI's exit before waiting for the stderr process substitution. Without the
|
|
514
603
|
// explicit wait, a short failing CLI can return while its error is still in the scrub pipe;
|
|
515
604
|
// under load the classifier then reads an empty ERRF and reports the wrong failure reason.
|
|
516
|
-
|
|
605
|
+
// #6134-followup: the time box has to fire from INSIDE the shell, while the process tree is
|
|
606
|
+
// still standing. Killing the turn's process group from node missed a grandchild — codex runs
|
|
607
|
+
// its own commands via setsid, so `sleep 400` sat in a different group and survived
|
|
608
|
+
// process.kill(-pid). Worse, by the time node's timeout has killed bash the survivors have been
|
|
609
|
+
// reparented to init, so there is no tree left to walk and nothing to sweep.
|
|
610
|
+
//
|
|
611
|
+
// So bash boxes itself: at the deadline it walks its own descendants and kills them bottom-up.
|
|
612
|
+
// setsid changes a process's group and session but NEVER its parent, so `pgrep -P` recursion
|
|
613
|
+
// reaches exactly the children that a group signal cannot. Children first, then the parent, so
|
|
614
|
+
// nothing gets reparented mid-sweep and escapes the walk.
|
|
615
|
+
//
|
|
616
|
+
// The marker file is how node learns the turn was cut rather than merely failing: an exit status
|
|
617
|
+
// alone cannot tell "killed at the box" from "the CLI died on its own".
|
|
618
|
+
const sweep = `sweep() { local p; for p in $(pgrep -P $1 2>/dev/null); do sweep $p; done; kill -KILL $1 2>/dev/null; }`;
|
|
619
|
+
const box = TURN_MAX_MS ? `
|
|
620
|
+
${sweep}
|
|
621
|
+
( sleep ${Math.ceil(TURN_MAX_MS / 1000)}
|
|
622
|
+
kill -0 $job 2>/dev/null || exit 0
|
|
623
|
+
: > ${CUTF}
|
|
624
|
+
sweep $job
|
|
625
|
+
) & boxpid=$!` : "boxpid=";
|
|
626
|
+
const shell = `set -o pipefail
|
|
627
|
+
{ ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}) &
|
|
628
|
+
job=$!${box}
|
|
629
|
+
wait $job; turn_exit=$?
|
|
630
|
+
[ -n "$boxpid" ] && kill $boxpid 2>/dev/null
|
|
631
|
+
wait
|
|
632
|
+
exit $turn_exit`;
|
|
517
633
|
const r = spawnSync("/bin/bash", ["-c", shell], {
|
|
518
|
-
|
|
634
|
+
// detached: bash leads its OWN process group, so the time box can kill the CLI and everything
|
|
635
|
+
// it spawned with one signal instead of orphaning the model process behind a dead shell.
|
|
636
|
+
// stdin is /dev/null for every seat (it already was for codex/kimi/dsh via `< /dev/null`):
|
|
637
|
+
// a detached group is a BACKGROUND group, and a background process that reads the terminal
|
|
638
|
+
// takes SIGTTIN and stops forever. Nothing here runs interactively — every CLI is in -p /
|
|
639
|
+
// exec / run mode — so closing stdin is what makes the group safe.
|
|
640
|
+
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
|
+
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
|
|
519
646
|
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
|
|
520
647
|
// A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
|
|
521
648
|
//
|
|
@@ -531,6 +658,16 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
531
658
|
maxBuffer: 16 * 1024 * 1024,
|
|
532
659
|
});
|
|
533
660
|
try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
|
|
661
|
+
// The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
|
|
662
|
+
// cut, not merely failed.
|
|
663
|
+
const boxed = existsSync(CUTF);
|
|
664
|
+
const cut = !!TURN_MAX_MS && (boxed || r.error?.code === "ETIMEDOUT");
|
|
665
|
+
if (cut) {
|
|
666
|
+
// Belt and braces after the shell's descendant sweep: anything still sharing the turn's group.
|
|
667
|
+
if (r.pid) { try { process.kill(-r.pid, "SIGKILL"); } catch {} }
|
|
668
|
+
try { unlinkSync(CUTF); } catch {}
|
|
669
|
+
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`);
|
|
670
|
+
}
|
|
534
671
|
// #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
|
|
535
672
|
// wait above drains the live stderr scrubber first; this pass is defense in depth for redaction.
|
|
536
673
|
try { writeFileSync(ERRF, redactKeys(readFileSync(ERRF, "utf8"))); } catch {}
|
|
@@ -542,6 +679,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
542
679
|
try { ownOut = stripPromptEcho(readFileSync(ERRF, "utf8"), readPromptText(pf)); } catch { ownOut = ""; }
|
|
543
680
|
lastErrText = ownOut.slice(-4000);
|
|
544
681
|
if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
|
|
682
|
+
// #6154: the opencode family prints no sid on stdout — the id comes from opencode's own DB,
|
|
683
|
+
// keyed by the worktree the session was created in. Fail-open: nothing found leaves sid empty,
|
|
684
|
+
// and the next turn starts fresh rather than resuming whatever other project ran last.
|
|
685
|
+
if (cli.pinned) { const found = ocSid(TURN_DIR); if (found) sid = found; }
|
|
545
686
|
const realExit = r.status;
|
|
546
687
|
// A zero exit is NOT proof the turn ran: opencode prints "401 Unauthorized" / "Invalid API key"
|
|
547
688
|
// and exits 0, so a bare 0 made the runner ack "✅ done", clear the pending queue and heartbeat
|
|
@@ -578,12 +719,23 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
578
719
|
// #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
|
|
579
720
|
// away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
|
|
580
721
|
const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut);
|
|
581
|
-
|
|
722
|
+
// #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
|
|
723
|
+
// never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
|
|
724
|
+
const tokens = parseTurnTokens(ownOut);
|
|
725
|
+
telemetry({ 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, ...(tokens ? { tokens } : {}), ...(cut ? { cut: true } : {}) });
|
|
582
726
|
log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
583
727
|
if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
|
|
584
728
|
// #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
|
|
585
729
|
// pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
|
|
586
730
|
if (realExit === 0 && effExit === 0) await registerStatus("idle");
|
|
731
|
+
// The follow-up rides the SAME session, so the model still has the turn it was cut out of and
|
|
732
|
+
// only has to land it. Exactly one — a follow-up that runs long is itself boxed, and boxing a
|
|
733
|
+
// boxed turn forever is the loop this card exists to end.
|
|
734
|
+
if (cut && !inFollowUp) {
|
|
735
|
+
inFollowUp = true;
|
|
736
|
+
try { return await runTurn(TIME_BOX_PROMPT, false, "time-box follow-up"); }
|
|
737
|
+
finally { inFollowUp = false; }
|
|
738
|
+
}
|
|
587
739
|
return effExit;
|
|
588
740
|
}
|
|
589
741
|
|
|
@@ -655,8 +807,19 @@ function isRunnerSession(session) {
|
|
|
655
807
|
|
|
656
808
|
function shouldWake(message) {
|
|
657
809
|
if (isReceipt(message) || isStatusBroadcast(message)) return false;
|
|
810
|
+
// #6134: the SENDER decides. `wake:false` says "this is context, not a contract" — it batches
|
|
811
|
+
// into the next turn's prompt like a broadcast and never buys a CLI session of its own.
|
|
812
|
+
if (message?.wake === false) return false;
|
|
658
813
|
if (message?.to === SESSION) {
|
|
659
814
|
if (message?.kind === "status") return false;
|
|
815
|
+
// The safety net for every sender that never set the flag: a direct message carrying no card
|
|
816
|
+
// and no instruction is an ack, an FYI or a queue note. Those made up most of the 09-02 burn.
|
|
817
|
+
// Two exemptions, both because the shape net reads WORDS and these carry their meaning in
|
|
818
|
+
// their type: a typed alert (a failure escalation, a bounce), and an OVERSEER warning that got
|
|
819
|
+
// this far — the one chatty overseer kind is already batched by name upstream, so anything
|
|
820
|
+
// still here is file-conflict or linked-activity, which #5760 deliberately kept waking.
|
|
821
|
+
const typed = message?.kind === "alert" || /^🤝 OVERSEER /.test(String(message?.text || ""));
|
|
822
|
+
if (!typed && !isContract(message) && !carriesWork(message?.text)) return false;
|
|
660
823
|
return !isRunnerSession(message?.from) || isContract(message);
|
|
661
824
|
}
|
|
662
825
|
return message?.to === "all"
|
|
@@ -764,7 +927,10 @@ function askedExcerpt(message) {
|
|
|
764
927
|
const rest = msgs.filter(m => !fyi.includes(m));
|
|
765
928
|
const direct = rest.filter(m => m.to === SESSION && shouldWake(m));
|
|
766
929
|
const mentions = rest.filter(m => m.to === "all" && shouldWake(m));
|
|
767
|
-
|
|
930
|
+
// Everything that did not earn a turn still becomes CONTEXT — including a DIRECT message that
|
|
931
|
+
// batched (wake:false, or an ack by shape). Dropping those would trade a token problem for a
|
|
932
|
+
// deafness problem: the seat would never learn what it was told (#6134).
|
|
933
|
+
const bcast = [...rest.filter(m => !direct.includes(m) && !mentions.includes(m)), ...fyi];
|
|
768
934
|
pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
|
|
769
935
|
const wake = [...direct, ...mentions];
|
|
770
936
|
if (!wake.length) { if (bcast.length) { savePending(pendingWake, pendingBcast); log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); } continue; }
|
|
@@ -810,22 +976,43 @@ function askedExcerpt(message) {
|
|
|
810
976
|
for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
|
|
811
977
|
const asked = askedExcerpt(wake[0]);
|
|
812
978
|
const tStart = Date.now();
|
|
979
|
+
// #6134: ONE SESSION PER CARD. A seat that resumes forever carries every card it ever worked
|
|
980
|
+
// into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
|
|
981
|
+
// card that moved this wake decides: a different one starts a fresh CLI session, and the seat
|
|
982
|
+
// is told so, because a fresh session remembers nothing and must be sent to its card.
|
|
983
|
+
const card = wake.map(m => cardRef(m.text)).find(Boolean) || 0;
|
|
984
|
+
const fresh = card > 0 && card !== sessionCard;
|
|
985
|
+
if (card) sessionCard = card;
|
|
986
|
+
const freshText = fresh
|
|
987
|
+
? `\n(FRESH SESSION for card #${card} — you are not the session that worked earlier cards and you remember none of them. Read your card first: relay_board with card:${card}.)\n`
|
|
988
|
+
: "";
|
|
813
989
|
const prompt = composedTurn({
|
|
814
|
-
wakeText, ctxText, againText,
|
|
990
|
+
wakeText, ctxText, againText: againText + freshText,
|
|
815
991
|
tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
|
|
816
992
|
rulesText: RULES, lessons,
|
|
817
993
|
});
|
|
818
|
-
const ec = await runTurn(prompt,
|
|
994
|
+
const ec = await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger);
|
|
819
995
|
const secs = Math.round((Date.now() - tStart) / 1000);
|
|
820
996
|
if (ec) {
|
|
821
997
|
deliveryFails++;
|
|
998
|
+
// #6131: a silent turn on a seat whose plan reads spent is exhaustion wearing a crash's
|
|
999
|
+
// clothes. Only that one reason is ever re-read, and only from the seat's own balance rows.
|
|
1000
|
+
let reason = classify(ec);
|
|
1001
|
+
if (reason === "empty-output") reason = reasonWithBalances(reason, await balanceRows());
|
|
1002
|
+
savePending(pendingWake, pendingBcast);
|
|
1003
|
+
await reportFailure(ec, "message", pendingWake.length, reason);
|
|
1004
|
+
if (PARKING_REASONS.has(reason)) {
|
|
1005
|
+
retryAt = await parkSeat(reason, pendingWake.length);
|
|
1006
|
+
await notifyAssigners(assigners,
|
|
1007
|
+
`⛔ your contract is PARKED on ${SESSION} (${reason}) — not retrying · asked: "${asked}"`);
|
|
1008
|
+
lastTurnAt = Date.now();
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
822
1011
|
const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
|
|
823
1012
|
retryAt = Date.now() + wait;
|
|
824
|
-
savePending(pendingWake, pendingBcast);
|
|
825
|
-
await reportFailure(ec, "message", pendingWake.length);
|
|
826
1013
|
// The room hears the broadcast above; the one who is actually blocked hears it directly.
|
|
827
1014
|
await notifyAssigners(assigners,
|
|
828
|
-
`⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${
|
|
1015
|
+
`⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${reason}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
|
|
829
1016
|
log(`\x1b[31m${pendingWake.length} message(s) still UNDELIVERED — next attempt in ${Math.round(wait / 1000)}s\x1b[0m`);
|
|
830
1017
|
} else {
|
|
831
1018
|
pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
|
package/bin/crew.sh
CHANGED
|
@@ -792,12 +792,16 @@ resolve_spec() {
|
|
|
792
792
|
if [ -n "$FIELD" ]; then
|
|
793
793
|
case "$FIELD" in
|
|
794
794
|
*/*) MODEL="$FIELD" ;;
|
|
795
|
-
|
|
795
|
+
# A NATIVE CLI takes its model id verbatim: `claude:opus`, `codex:o3`, `kimi:k2`. The router
|
|
796
|
+
# only knows opencode providers, so it refused `claude:opus` outright (2026-09-03) and the
|
|
797
|
+
# seat silently ran the CLI default, which for claude was the most expensive model on the plan.
|
|
798
|
+
*) if [[ "$AGENT" =~ ^(claude|codex|kimi|gemini)$ ]]; then MODEL="$FIELD"; echo " → $AGENT: model $MODEL (native pin)"; else
|
|
799
|
+
MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" || {
|
|
796
800
|
echo "[crew] ✗ skipping seat '$AGENT' — model resolution failed for $FIELD ($TASK/$DIFF); remaining seats still launch" >&2
|
|
797
801
|
SKIPPED_SEATS+=("$AGENT: model resolution failed for $FIELD ($TASK/$DIFF)")
|
|
798
802
|
return 1
|
|
799
803
|
}
|
|
800
|
-
echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)" ;;
|
|
804
|
+
echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)"; fi ;;
|
|
801
805
|
esac
|
|
802
806
|
fi
|
|
803
807
|
}
|
package/bin/seat-why.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// trantor seat-why <agent> [--json] — explain why a crew seat is (not) working, straight from
|
|
3
3
|
// local ~/.agent-bus evidence. No hub needed; works even when the whole fleet is down.
|
|
4
|
-
import { seatWhy } from "../lib/seat-why.mjs";
|
|
4
|
+
import { seatWhy, fmtSpend } from "../lib/seat-why.mjs";
|
|
5
5
|
import { resolveProject } from "../lib/project.mjs";
|
|
6
6
|
|
|
7
7
|
const args = process.argv.slice(2);
|
|
@@ -23,5 +23,6 @@ if (asJson) {
|
|
|
23
23
|
} else {
|
|
24
24
|
console.log(`seat ${agent}:${project} -> ${out.state}`);
|
|
25
25
|
console.log(`why: ${out.why}`);
|
|
26
|
+
console.log(`today: ${fmtSpend(out.today)}`);
|
|
26
27
|
console.log(`advice: ${out.advice}`);
|
|
27
28
|
}
|
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
|
}
|
|
@@ -1447,6 +1452,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1447
1452
|
const raw = req._rawBody || "";
|
|
1448
1453
|
const verified = verifyRequest({ headers: req.headers, method: req.method, path: authPath(u), body: raw });
|
|
1449
1454
|
if (!verified.ok) return json(res, 401, { error: verified.reason || "bad signature" });
|
|
1455
|
+
const requestedKind = String(b0.kind || "agent").slice(0, 40);
|
|
1456
|
+
if (!IDENTITY_KINDS.includes(requestedKind)) {
|
|
1457
|
+
return json(res, 400, { error: `kind must be one of: ${IDENTITY_KINDS.join(", ")}`, allowedKinds: IDENTITY_KINDS });
|
|
1458
|
+
}
|
|
1450
1459
|
const existing = findIdentity(verified.pubkey);
|
|
1451
1460
|
if (existing) return json(res, 200, { ok: true, identity: publicView(existing), scopes: existing.scopes || [] });
|
|
1452
1461
|
let enrolledBy = "";
|
|
@@ -1482,7 +1491,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1482
1491
|
}
|
|
1483
1492
|
const identity = {
|
|
1484
1493
|
name: String(b0.name || "").slice(0, 120) || verified.pubkey.slice(0, 16),
|
|
1485
|
-
kind:
|
|
1494
|
+
kind: requestedKind,
|
|
1486
1495
|
pubkey: verified.pubkey,
|
|
1487
1496
|
createdAt: now(),
|
|
1488
1497
|
enrolledBy,
|
|
@@ -1501,6 +1510,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1501
1510
|
const az = authorize(auth, req.method, P, "*");
|
|
1502
1511
|
if (!az.ok) return json(res, az.code || 403, { error: az.error || "forbidden" });
|
|
1503
1512
|
const bi = await body(req);
|
|
1513
|
+
const invitedKind = String(bi.kind || "agent").slice(0, 40);
|
|
1514
|
+
if (!IDENTITY_KINDS.includes(invitedKind)) {
|
|
1515
|
+
return json(res, 400, { error: `kind must be one of: ${IDENTITY_KINDS.join(", ")}`, allowedKinds: IDENTITY_KINDS });
|
|
1516
|
+
}
|
|
1504
1517
|
const scopes = (Array.isArray(bi.scopes) ? bi.scopes : []).map(cleanScope).filter(Boolean).slice(0, 20);
|
|
1505
1518
|
if (!scopes.length) return json(res, 400, { error: "scopes required" });
|
|
1506
1519
|
// Honour the requested TTL. A 60s FLOOR here silently inflated `ttlSec: 1` to a minute, so a
|
|
@@ -1508,7 +1521,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1508
1521
|
// a live token straight through /enroll. Cap the ceiling, never the floor.
|
|
1509
1522
|
const ttlSec = Math.min(Math.max(Number(bi.ttlSec) || 86400, 1), 30 * 86400);
|
|
1510
1523
|
const token = randomBytes(24).toString("hex");
|
|
1511
|
-
state.inviteTokens[token] = { scopes, expiresAt: now() + ttlSec * 1000, used: false,
|
|
1524
|
+
state.inviteTokens[token] = { scopes, kind: invitedKind, expiresAt: now() + ttlSec * 1000, used: false,
|
|
1512
1525
|
createdBy: auth.identity?.pubkey || "", createdAt: now() };
|
|
1513
1526
|
dirty = true;
|
|
1514
1527
|
return json(res, 200, { ok: true, token, scopes, expiresAt: state.inviteTokens[token].expiresAt });
|
|
@@ -2786,7 +2799,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2786
2799
|
// side, which is how an orchestrator ends up waiting forever on a dead peer.
|
|
2787
2800
|
const re = Number.isFinite(Number(b.re)) && Number(b.re) > 0 ? Number(b.re) : 0;
|
|
2788
2801
|
const kind = String(b.kind || "").slice(0, 40);
|
|
2789
|
-
|
|
2802
|
+
// `wake:false` is the SENDER saying this message is context, not a contract: the receiving
|
|
2803
|
+
// runner batches it into that seat's next turn instead of spending a whole CLI session on
|
|
2804
|
+
// it (#6134). Stored only when false — absent means wake, so every older client is unchanged.
|
|
2805
|
+
const wake = b.wake === false ? { wake: false } : {};
|
|
2806
|
+
const msg = { id: ++state.seq, ts: now(), from: b.from || "anon", to: b.to || "all", text, project: String(b.project || fromProj || "").slice(0, 80), ...(re ? { re } : {}), ...(kind ? { kind } : {}), ...wake };
|
|
2790
2807
|
state.messages.push(msg); if (state.messages.length > 5000) state.messages.splice(0, 1000);
|
|
2791
2808
|
dirty = true; pushToStreams(msg); // <-- instant push to live watchers
|
|
2792
2809
|
// Mirror onto the unified log. `refs` = the card ids this message cites (#3701), which is what
|
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/seat-why.mjs
CHANGED
|
@@ -61,6 +61,32 @@ const rel = (ts) => {
|
|
|
61
61
|
return `${Math.round(s / 3600)}h ago`;
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
// What this seat has SPENT today (#6134). The turn count is the number that mattered on 09-02:
|
|
65
|
+
// 151 turns and ~16 agentic hours across the fleet, most of it redelivery and coordination noise.
|
|
66
|
+
// Tokens come from each CLI's own usage line (the runner parses it onto the turn's row); CLIs that
|
|
67
|
+
// print none contribute 0, which is why the count is reported alongside — "3 of 7 turns reported".
|
|
68
|
+
export function todaySpend(telemetry, now = Date.now()) {
|
|
69
|
+
const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
|
|
70
|
+
const rows = telemetry.filter(r => typeof r.turn === "number" && r.ts >= midnight.getTime());
|
|
71
|
+
const withTokens = rows.filter(r => Number(r.tokens) > 0);
|
|
72
|
+
return {
|
|
73
|
+
turns: rows.length,
|
|
74
|
+
minutes: Math.round(rows.reduce((a, r) => a + (Number(r.duration_ms) || 0), 0) / 60000),
|
|
75
|
+
tokens: withTokens.reduce((a, r) => a + Number(r.tokens), 0),
|
|
76
|
+
reported: withTokens.length,
|
|
77
|
+
cut: rows.filter(r => r.cut).length,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function fmtSpend(s) {
|
|
82
|
+
const bits = [`${s.turns} turn${s.turns === 1 ? "" : "s"}`, `${s.minutes}m`];
|
|
83
|
+
bits.push(s.reported
|
|
84
|
+
? `${s.tokens.toLocaleString("en-US")} tokens (${s.reported}/${s.turns} turns reported)`
|
|
85
|
+
: "tokens not reported by this CLI");
|
|
86
|
+
if (s.cut) bits.push(`${s.cut} cut at the time box`);
|
|
87
|
+
return bits.join(" · ");
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
export function seatWhy(project, agent, opts = {}) {
|
|
65
91
|
const dir = opts.dir || busDir();
|
|
66
92
|
const errText = readF(join(dir, `err-${agent}-${project}.txt`));
|
|
@@ -117,7 +143,7 @@ export function seatWhy(project, agent, opts = {}) {
|
|
|
117
143
|
}
|
|
118
144
|
}
|
|
119
145
|
|
|
120
|
-
return { state, why, advice };
|
|
146
|
+
return { state, why, advice, today: todaySpend(telemetry, opts.now) };
|
|
121
147
|
}
|
|
122
148
|
|
|
123
149
|
export { fmt, rel };
|
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]);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Per-turn policy for a crew seat (#6134) — the rules that decide whether a bus message is worth
|
|
2
|
+
// a CLI turn at all, which card a session belongs to, what a turn cost, and when an exhausted seat
|
|
3
|
+
// is allowed to be woken again.
|
|
4
|
+
//
|
|
5
|
+
// These live here rather than in bin/crew-runner.mjs because the runner is a self-executing script
|
|
6
|
+
// (top-level await, spawns turns on import), so nothing in it can be unit-tested directly. The
|
|
7
|
+
// drill still drives the REAL runner end to end; these functions are what it can assert on cheaply.
|
|
8
|
+
//
|
|
9
|
+
// The burn this exists to stop: on 09-02 the fleet spent 151 turns and ~16 agentic hours, codex
|
|
10
|
+
// alone taking 60 turns of pure redelivery, and qwen 85.7M tokens at 96.7% cached — i.e. the same
|
|
11
|
+
// history replayed over and over. Every rule below removes one class of turn that never had work
|
|
12
|
+
// in it.
|
|
13
|
+
|
|
14
|
+
/// The first card a message cites. A turn belongs to exactly one card, and this is how the runner
|
|
15
|
+
/// knows when a wake has moved to a different one.
|
|
16
|
+
export const CARD_REF_RE = /#(\d{1,7})(?!\d)/;
|
|
17
|
+
|
|
18
|
+
/// Words that make a direct message an instruction rather than conversation. Deliberately short:
|
|
19
|
+
/// the point is to catch a contract that forgot to cite its card, not to parse English.
|
|
20
|
+
export const IMPERATIVE_RE = /\b(deliver|fix|bounce|contract|next|resume)\b/i;
|
|
21
|
+
|
|
22
|
+
export function cardRef(text) {
|
|
23
|
+
const m = CARD_REF_RE.exec(String(text || ""));
|
|
24
|
+
return m ? Number(m[1]) : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hasImperative(text) {
|
|
28
|
+
return IMPERATIVE_RE.test(String(text || ""));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The safety net for a sender that never set `wake`: a direct message with no card and no
|
|
32
|
+
/// imperative is an ack, an FYI or a queue note, and it batches into the next turn's context.
|
|
33
|
+
export function carriesWork(text) {
|
|
34
|
+
return cardRef(text) > 0 || hasImperative(text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- what a turn cost -------------------------------------------------------------------------
|
|
38
|
+
// Each CLI reports its own usage in its own words, and some report none at all. Ordered most
|
|
39
|
+
// specific first; the LAST match of the first pattern that hits wins, because a CLI that prints a
|
|
40
|
+
// running total prints the real one last. Zero means "this CLI said nothing", never "free".
|
|
41
|
+
const TOKEN_PATTERNS = [
|
|
42
|
+
/tokens used[:\s]+([\d,]+)/gi, // codex
|
|
43
|
+
/\btotal tokens[:\s]+([\d,]+)/gi, // opencode / glm / deepseek summaries
|
|
44
|
+
/\btokens[:\s]+([\d,]+)/gi, // "Tokens: 12,345"
|
|
45
|
+
/([\d,]+)\s+tokens\b/gi, // "12,345 tokens"
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function parseTurnTokens(text) {
|
|
49
|
+
const s = String(text || "");
|
|
50
|
+
for (const re of TOKEN_PATTERNS) {
|
|
51
|
+
re.lastIndex = 0;
|
|
52
|
+
let last = 0;
|
|
53
|
+
for (const m of s.matchAll(re)) {
|
|
54
|
+
const n = Number(String(m[1]).replace(/,/g, ""));
|
|
55
|
+
if (Number.isFinite(n)) last = n;
|
|
56
|
+
}
|
|
57
|
+
if (last) return last;
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- when an exhausted seat may be woken again ------------------------------------------------
|
|
63
|
+
// A CLI that hits its plan wall usually says when the wall lifts. Parsing it turns a blind retry
|
|
64
|
+
// ladder (60 redelivery turns on codex, 09-02) into one wait.
|
|
65
|
+
const MONTHS = "jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec";
|
|
66
|
+
const RESET_ABS_RE = new RegExp(
|
|
67
|
+
String.raw`try again (?:at|on|after)\s+((?:${MONTHS})[a-z]*\.?\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4},?\s+\d{1,2}:\d{2}(?::\d{2})?\s*(?:am|pm)?)`,
|
|
68
|
+
"i",
|
|
69
|
+
);
|
|
70
|
+
const RESET_REL_RE = /try again in\s+(\d+)\s*(second|minute|hour|day)s?/i;
|
|
71
|
+
|
|
72
|
+
/// The epoch ms at which this seat may be retried, or 0 when the output named no time.
|
|
73
|
+
export function parseResetAt(text, now = Date.now()) {
|
|
74
|
+
const s = String(text || "");
|
|
75
|
+
|
|
76
|
+
const abs = RESET_ABS_RE.exec(s);
|
|
77
|
+
if (abs) {
|
|
78
|
+
// "Sep 3rd, 2026 3:34 AM" — Date.parse rejects the ordinal suffix, so drop it.
|
|
79
|
+
const t = Date.parse(abs[1].replace(/(\d{1,2})(st|nd|rd|th)/i, "$1"));
|
|
80
|
+
if (Number.isFinite(t) && t > now) return t;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rel = RESET_REL_RE.exec(s);
|
|
84
|
+
if (rel) {
|
|
85
|
+
const unit = { second: 1e3, minute: 60e3, hour: 3600e3, day: 86400e3 }[rel[2].toLowerCase()];
|
|
86
|
+
if (unit) return now + Number(rel[1]) * unit;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// #6131: a qwen seat whose token plan is spent does not error — it stalls and returns nothing, so
|
|
93
|
+
/// the runner classified it `empty-output` and kept the ladder running against a wall. A silent
|
|
94
|
+
/// turn on a seat whose own balance row reads spent IS exhaustion, and parks like one.
|
|
95
|
+
export function quotaSpent(rows) {
|
|
96
|
+
return (Array.isArray(rows) ? rows : []).some((r) => {
|
|
97
|
+
if (!r || !r.ok) return false;
|
|
98
|
+
if (r.kind === "quota") return r.remainingPct != null && r.remainingPct <= 0;
|
|
99
|
+
if (r.kind === "windows") {
|
|
100
|
+
return (r.windows || []).some((w) => w.locked || (w.usedPct != null && w.usedPct >= 100));
|
|
101
|
+
}
|
|
102
|
+
return r.remaining != null && r.remaining <= 0;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// The failure reason a turn should be treated as, given what the seat's balances say. Only
|
|
107
|
+
/// `empty-output` is ever re-read this way: every other reason already carries its own evidence.
|
|
108
|
+
export function reasonWithBalances(reason, rows) {
|
|
109
|
+
return reason === "empty-output" && quotaSpent(rows) ? "exhausted" : reason;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Seats that park rather than retry. A backend error is the provider having a bad minute and the
|
|
113
|
+
/// ladder is exactly right for it; a spent plan or a rejected key will not fix itself on a timer.
|
|
114
|
+
export const PARKING_REASONS = new Set(["exhausted", "auth"]);
|
package/mcp.mjs
CHANGED
|
@@ -314,12 +314,55 @@ server.tool("relay_withdraw_proposal", "Withdraw one of THIS session's PENDING p
|
|
|
314
314
|
return { content: [{ type: "text", text: `proposal #${id} withdrawn — one queue slot free` }] };
|
|
315
315
|
});
|
|
316
316
|
|
|
317
|
+
// ONE card, not the board (#6134). A seat used to be told to "query the board for related PAST
|
|
318
|
+
// cards and lessons — 1900+ cards of tribal knowledge", and it did: the whole board, every turn,
|
|
319
|
+
// almost all of it other people's work. This is the same intent at a thousandth of the tokens —
|
|
320
|
+
// the card, what it waits on, its own notes, and the handful of done cards that actually rhyme
|
|
321
|
+
// with it. Client-side on /tasks, so no hub change and it works against any hub version.
|
|
322
|
+
const STOPWORDS = new Set(["the","a","an","and","or","of","to","in","on","for","with","is","it","its","that","this","not","but","by","at","as","from","into","out","up","down","when","then","than","so","no","new","one","two","every","all","any"]);
|
|
323
|
+
function titleWords(title) {
|
|
324
|
+
return new Set(String(title || "").toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g)?.filter(w => !STOPWORDS.has(w)) || []);
|
|
325
|
+
}
|
|
326
|
+
function cardView(tasks, id, proj) {
|
|
327
|
+
const card = tasks.find(t => t.id === id);
|
|
328
|
+
if (!card) return `${proj}: no card #${id}`;
|
|
329
|
+
const out = [`#${card.id} ${card.title}`,
|
|
330
|
+
`status: ${card.status}${card.assignee ? ` · @${card.assignee}` : ""}${card.difficulty ? ` · ${card.difficulty}` : ""}${card.model ? ` · ${card.model}` : ""}`];
|
|
331
|
+
|
|
332
|
+
const deps = (Array.isArray(card.deps) ? card.deps : []).map(d => {
|
|
333
|
+
const t = tasks.find(x => x.id === d);
|
|
334
|
+
return t ? `#${t.id} ${t.title} [${t.status}]` : `#${d} (not on this board)`;
|
|
335
|
+
});
|
|
336
|
+
if (deps.length) out.push(`depends on:\n ${deps.join("\n ")}`);
|
|
337
|
+
|
|
338
|
+
if (Array.isArray(card.checklist) && card.checklist.length) {
|
|
339
|
+
out.push(`checklist:\n ${card.checklist.map((c, i) => `[${c.done ? "x" : " "}] ${i}. ${c.text || c}`).join("\n ")}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const log = Array.isArray(card.log) ? card.log : [];
|
|
343
|
+
if (log.length) out.push(`notes (${log.length}):\n ${log.map(e => `${e.by || "?"}: ${String(e.text || "").replace(/\s+/g, " ")}`).join("\n ")}`);
|
|
344
|
+
|
|
345
|
+
// The prior art that is actually prior art: done cards whose title shares a real word with this
|
|
346
|
+
// one. Five, newest first — enough to catch "we already did this", short enough to stay cheap.
|
|
347
|
+
const mine = titleWords(card.title);
|
|
348
|
+
const kin = tasks
|
|
349
|
+
.filter(t => t.status === "done" && t.id !== card.id && [...titleWords(t.title)].some(w => mine.has(w)))
|
|
350
|
+
.sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
|
|
351
|
+
.slice(0, 5)
|
|
352
|
+
.map(t => `#${t.id} ${t.title}${t.log?.length ? ` ·${t.log.length}` : ""}`);
|
|
353
|
+
if (kin.length) out.push(`related done cards:\n ${kin.join("\n ")}`);
|
|
354
|
+
|
|
355
|
+
return out.join("\n");
|
|
356
|
+
}
|
|
357
|
+
|
|
317
358
|
server.tool("relay_board", "Show a project's Kanban board (all cards + their status + assignee). Defaults to THIS project; pass `project` to read a crew board you orchestrate from elsewhere. Cards carrying log notes show a ·N count (the card's note-log size).",
|
|
318
|
-
{ project: z.string().optional().describe("board to show (default: this session's project)")
|
|
319
|
-
|
|
359
|
+
{ project: z.string().optional().describe("board to show (default: this session's project)"),
|
|
360
|
+
card: z.number().optional().describe("read ONE card instead of the board: the card itself, its notes, and the last five done cards whose title shares a word with it. This is what a seat starting a card should call — the whole board is 1900+ cards of someone else's work.") },
|
|
361
|
+
async ({ project, card }) => {
|
|
320
362
|
const proj = project || PROJECT;
|
|
321
363
|
const { tasks } = await api("GET", `/tasks?project=${encodeURIComponent(proj)}`);
|
|
322
364
|
if (!tasks.length) return { content: [{ type: "text", text: `${proj}: no cards yet` }] };
|
|
365
|
+
if (card) return { content: [{ type: "text", text: cardView(tasks, card, proj) }] };
|
|
323
366
|
const by = { todo: [], doing: [], testing: [], failed: [], done: [], blocked: [] };
|
|
324
367
|
for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${t.log?.length ? ` ·${t.log.length}` : ""}`);
|
|
325
368
|
const cols = Object.entries(by).filter(([, v]) => v.length).map(([k, v]) => `${k.toUpperCase()}:\n ${v.join("\n ")}`);
|
|
@@ -338,16 +381,17 @@ server.tool("relay_peers", "Find who you can talk to: the live agent sessions on
|
|
|
338
381
|
});
|
|
339
382
|
|
|
340
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 sends are allowed.",
|
|
341
|
-
{ to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body")
|
|
342
|
-
|
|
384
|
+
{ to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body"),
|
|
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
|
+
async ({ to, text, wake }) => {
|
|
343
387
|
// The event log is append-only — a secret in it is unrecoverable, so refuse BEFORE
|
|
344
388
|
// anything reaches the hub. Returns the offending kinds so the caller can fix it.
|
|
345
389
|
const scrub = assertNoSecrets(text);
|
|
346
390
|
if (!scrub.ok) {
|
|
347
391
|
return { content: [{ type: "text", text: `REFUSED — not sent. Credential-shaped string(s) detected: ${scrub.kinds.join(", ")}. Remove them and resend.` }], isError: true };
|
|
348
392
|
}
|
|
349
|
-
const { id } = await api("POST", "/send", { from: SESSION, to, text });
|
|
350
|
-
return { content: [{ type: "text", text: `sent #${id} to ${to}` }] };
|
|
393
|
+
const { id } = await api("POST", "/send", { from: SESSION, to, text, ...(wake === false ? { wake: false } : {}) });
|
|
394
|
+
return { content: [{ type: "text", text: `sent #${id} to ${to}${wake === false ? " (batched — no turn)" : ""}` }] };
|
|
351
395
|
});
|
|
352
396
|
|
|
353
397
|
server.tool("relay_status", "Set this session's one-line status on the presence board (what you're working on / idle). Cheap — other sessions read it instantly via relay_peers without messaging you.",
|
|
@@ -436,8 +480,15 @@ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
|
436
480
|
const nonProjectReason = nonSeatReason(projectDir);
|
|
437
481
|
const isHomeDirSession = !!nonProjectReason;
|
|
438
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
|
+
|
|
439
490
|
if (!isHomeDirSession) {
|
|
440
|
-
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 })
|
|
441
492
|
.catch((err) => { process.stderr.write(`[trantor-mcp] initial register failed: ${err?.message || err}\n`); });
|
|
442
493
|
|
|
443
494
|
// Heartbeat — keep this session's presence fresh for as long as the MCP process lives.
|
|
@@ -449,7 +500,7 @@ if (!isHomeDirSession) {
|
|
|
449
500
|
// hub refreshes lastSeen but preserves the session's meaningful status. setInterval pauses during
|
|
450
501
|
// sleep and fires on wake, so presence self-heals within one interval; .unref() lets the process
|
|
451
502
|
// still exit cleanly when the agent closes the stdio transport (no phantom peers).
|
|
452
|
-
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?.();
|
|
453
504
|
} else {
|
|
454
505
|
process.stderr.write(`[trantor-mcp] ${nonProjectReason} — not auto-registering on the bus (set RELAY_SESSION or RELAY_PROJECT to opt in)\n`);
|
|
455
506
|
}
|