trantor 0.18.69 → 0.18.71
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 +11 -20
- package/bin/crew-runner.mjs +38 -23
- package/bin/seat-why.mjs +7 -1
- package/bin/seats.mjs +40 -34
- package/bin/turn-watchdog.mjs +20 -2
- package/hooks/lib/handoff.mjs +8 -0
- package/hub/routes/admin.mjs +4 -1
- package/lib/seat-why.mjs +44 -10
- package/lib/seats.mjs +93 -22
- package/lib/turn-usage.mjs +116 -0
- package/lib/turnstate.mjs +49 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.71",
|
|
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
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// a whole-session handoff (auto-summary + verbatim in-flight tail), opens a fresh self-announcing
|
|
4
4
|
// session, and closes THIS window once it takes over. Run from inside the session you want to hand
|
|
5
5
|
// off. (The richer MODEL-authored handoff is the /trantor:handoff skill.)
|
|
6
|
-
import { readdirSync, statSync
|
|
6
|
+
import { readdirSync, statSync } from "node:fs";
|
|
7
7
|
import { join, basename, dirname } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { writeHandoff, spawnBaton, resolveHandoffSurface, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs, sessionProcessState } from "../hooks/lib/handoff.mjs";
|
|
11
|
+
import { writeHandoff, spawnBaton, resolveHandoffSurface, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs, sessionProcessState, stdinCarriesMarkdown } from "../hooks/lib/handoff.mjs";
|
|
12
12
|
|
|
13
13
|
// #6074: the skill path (write-handoff.mjs) and this CLI path must share ONE resolution of which
|
|
14
14
|
// project this is and where the session lives. Both call resolveHandoffSurface; the name comes
|
|
@@ -22,18 +22,11 @@ const project = resolved.project;
|
|
|
22
22
|
// cwd that cd'd somewhere else: a handoff record must carry the transcript it was written from.
|
|
23
23
|
const sessionDir = resolved.projectDir;
|
|
24
24
|
|
|
25
|
-
// Model-authored handoffs ride THIS binary too
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
// character devices, so a plain `trantor handoff` typed at a prompt (or run by a hook with stdin
|
|
31
|
-
// at /dev/null) keeps the auto-summary behavior. Detection must NOT be `!process.stdin.isTTY` —
|
|
32
|
-
// that misreads /dev/null as a handoff and errors where auto-summary used to work.
|
|
33
|
-
function stdinIsPipe() {
|
|
34
|
-
try { return fstatSync(0).isFIFO(); } catch { return false; }
|
|
35
|
-
}
|
|
36
|
-
if (stdinIsPipe() || process.argv.includes("--latest")) {
|
|
25
|
+
// Model-authored handoffs ride THIS binary too — always the global install's CURRENT code, never
|
|
26
|
+
// the plugin-cache copy a session booted with (the stale-0.18.20 bug). Markdown on stdin means
|
|
27
|
+
// "here is the handoff", so forward to write-handoff.mjs in the SAME package; --latest forwards too.
|
|
28
|
+
// What counts as markdown, and why /dev/null must not: stdinCarriesMarkdown in hooks/lib (#8459).
|
|
29
|
+
if (stdinCarriesMarkdown() || process.argv.includes("--latest")) {
|
|
37
30
|
const helper = join(dirname(fileURLToPath(import.meta.url)), "write-handoff.mjs");
|
|
38
31
|
const child = spawn(process.execPath, [helper, ...process.argv.slice(2)], { stdio: ["inherit", "inherit", "inherit"] });
|
|
39
32
|
child.on("exit", (c) => process.exit(c ?? 1));
|
|
@@ -64,12 +57,10 @@ function autoBaton() {
|
|
|
64
57
|
// The transcript's filename IS the writing session's id — record it, or an orchestrator-thread
|
|
65
58
|
// handoff carries no writer and the baton-hold + map-follow logic in sessionstart.mjs can't fire.
|
|
66
59
|
const sessionId = transcript ? basename(transcript, ".jsonl") : "";
|
|
67
|
-
// #6528: WHO pulled the trigger. A TTY stdin
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// --reason rides through from the app (`--reason clicked|countdown|unattended`) so the RECORD
|
|
72
|
-
// finally names the real trigger instead of laundering every banner fire into "manual-cli".
|
|
60
|
+
// #6528: WHO pulled the trigger. A TTY stdin is a human typing it; the app chain (handoff_now)
|
|
61
|
+
// and hooks spawn this with piped stdio. Only the typed command keeps the storm-guard bypass
|
|
62
|
+
// (manual = intentional); every invoked path goes through the boundary gate like an auto handoff.
|
|
63
|
+
// --reason rides through from the app so the record names the real trigger, not "manual-cli".
|
|
73
64
|
const reasonArg = (() => {
|
|
74
65
|
const i = process.argv.indexOf("--reason");
|
|
75
66
|
const v = i >= 0 ? String(process.argv[i + 1] || "").trim() : "";
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { loadOrCreate } from "../lib/identity.mjs";
|
|
|
11
11
|
import { signedHeaders } from "../lib/signed-fetch.mjs";
|
|
12
12
|
import { ensureEnrolled } from "../lib/enroll.mjs";
|
|
13
13
|
import { redactKeys } from "../lib/redact.mjs";
|
|
14
|
+
import { writeTurnState } from "../lib/turnstate.mjs";
|
|
14
15
|
import { applyWorktreeDeclaration, ensureSeatWorktree, provisioningLines, readWorktreeDeclaration } from "../lib/seat-worktree.mjs";
|
|
15
16
|
import {
|
|
16
17
|
AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
|
|
24
25
|
CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
|
|
25
26
|
} from "../lib/turn-policy.mjs";
|
|
27
|
+
import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
|
|
26
28
|
import {
|
|
27
29
|
auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
|
|
28
30
|
observedDutyNudgeIds, requeueMissingWakeMessages, shedExpiredHubAlerts,
|
|
@@ -459,6 +461,10 @@ async function parkSeat(reason, undelivered, resetHint = 0, { evidence = "" } =
|
|
|
459
461
|
// the operator can hear out of band, once per park.
|
|
460
462
|
notifyOperator(`Trantor: ${SESSION} PARKED (${reason})`,
|
|
461
463
|
`${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
|
|
464
|
+
// #7749: parked is a first-class phase — the file and the peer row both say so, so an
|
|
465
|
+
// orchestrator stops confusing a quota-held seat with a working or a dead one.
|
|
466
|
+
writeTurnState(AGENT, PROJ, { phase: "parked", since: Date.now() });
|
|
467
|
+
await registerStatus(`parked (${reason})`, { phase: "parked", phaseSince: Date.now() });
|
|
462
468
|
return until;
|
|
463
469
|
}
|
|
464
470
|
|
|
@@ -515,9 +521,10 @@ async function balanceRows() {
|
|
|
515
521
|
// ---- activity truth (#5965): the RUNNER is the source for this seat ----------------
|
|
516
522
|
// herdr cannot see a runner-driven CLI mid-turn, so the runner reports turn boundaries to the hub:
|
|
517
523
|
// `working · <trigger>` at start, `idle` on a clean landing. Bounded 5s, one call per transition.
|
|
518
|
-
|
|
524
|
+
// #7749: the live phase + its start ride the same call, so /peers answers what the seat does NOW.
|
|
525
|
+
async function registerStatus(status, extra = {}) {
|
|
519
526
|
const url = HUB + "/register";
|
|
520
|
-
const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL });
|
|
527
|
+
const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, ...extra });
|
|
521
528
|
try {
|
|
522
529
|
const opts = { method: "POST", headers: { "content-type": "application/json", connection: "close" }, body };
|
|
523
530
|
await fetch(url, { ...opts, headers: { ...opts.headers, ...signedHeaders(identity, url, opts) }, signal: AbortSignal.timeout(5000) });
|
|
@@ -703,7 +710,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
703
710
|
// #5965 — TURN START. The hub peer row is where the app reads activity from, and the runner is
|
|
704
711
|
// the only one who knows a turn is starting, so say so before the CLI spawn (awaited: the spawn
|
|
705
712
|
// below blocks the loop, an unawaited fetch would not leave the machine until the turn ended).
|
|
706
|
-
await registerStatus(`working · ${trigger}
|
|
713
|
+
await registerStatus(`working · ${trigger}`, { phase: "working", phaseSince: t0 });
|
|
714
|
+
// #7749: the same boundary on disk — the watchdog refreshes lastBytesAt/lastTranscriptAt while
|
|
715
|
+
// the turn runs, so a live reader sees the turn advance without waiting for its end.
|
|
716
|
+
writeTurnState(AGENT, PROJ, { turn: TURN, phase: "working", since: t0, card: sessionCard || 0, lastBytesAt: t0, lastTranscriptAt: 0 });
|
|
707
717
|
const pf = join(homedir(), ".agent-bus", `turn-${AGENT}-${PROJ}.txt`);
|
|
708
718
|
appendFileSync(pf, "", { flag: "w" }); // truncate
|
|
709
719
|
appendFileSync(pf, prompt);
|
|
@@ -786,7 +796,9 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
786
796
|
const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB, TRANSCRIPT_DIR, TURN_DIR,
|
|
787
797
|
// #7752: the stall marker only exists when a box does — with no box there is no sweep to
|
|
788
798
|
// end the turn, so the watchdog stays report-only (reporting is the whole job, boxless).
|
|
789
|
-
TURN_MAX_MS ? STALLF : ""
|
|
799
|
+
TURN_MAX_MS ? STALLF : "",
|
|
800
|
+
// #7749: the turn-state file the watchdog refreshes mid-turn (lastBytesAt/lastTranscriptAt).
|
|
801
|
+
join(homedir(), ".agent-bus", `turnstate-${AGENT}-${PROJ}.json`)],
|
|
790
802
|
{ detached: true, stdio: "ignore" });
|
|
791
803
|
WD_CHILD = wd;
|
|
792
804
|
wd.unref();
|
|
@@ -875,6 +887,12 @@ exit $turn_exit`;
|
|
|
875
887
|
}
|
|
876
888
|
lastTurnCut = cut;
|
|
877
889
|
lastTurnStalled = cut && stallCut;
|
|
890
|
+
// #7749: the cut boundary lands on disk and on the peer row BEFORE the follow-up turn overwrites
|
|
891
|
+
// it — a reader sampling mid-chain sees what actually just happened, not a stale "working".
|
|
892
|
+
if (cut) {
|
|
893
|
+
writeTurnState(AGENT, PROJ, { phase: stallCut ? "stalled" : "cut", since: Date.now() });
|
|
894
|
+
await registerStatus(stallCut ? "stalled · no activity" : "cut at the time box", { phase: stallCut ? "stalled" : "cut", phaseSince: Date.now() });
|
|
895
|
+
}
|
|
878
896
|
// #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
|
|
879
897
|
// wait above drains the live stderr scrubber first; this pass is defense in depth for redaction.
|
|
880
898
|
try { writeFileSync(ERRF, redactKeys(readFileSync(ERRF, "utf8"))); } catch {}
|
|
@@ -940,6 +958,13 @@ exit $turn_exit`;
|
|
|
940
958
|
// actually carried counts.
|
|
941
959
|
if (c) tokens = c.input + c.output + c.cache_read + c.cache_creation;
|
|
942
960
|
}
|
|
961
|
+
// #8234: the CLIs that print no usage on stdout keep the counts in their own session records —
|
|
962
|
+
// opencode's db, the session resolved for this turn exactly like the `-s` resume id. Sum this
|
|
963
|
+
// turn's window there; null (unknown) leaves the stdout paths above standing — never 0 ("free").
|
|
964
|
+
const usage = cli.pinned && sid ? ocTurnUsage(OC_DB, sid, t0, Date.now())
|
|
965
|
+
: AGENT === "dsh" ? dshTurnUsage(join(homedir(), ".dsh", "sessions"), TURN_DIR, t0, Date.now())
|
|
966
|
+
: null;
|
|
967
|
+
if (usage) tokens = usageTotal(usage);
|
|
943
968
|
// #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
|
|
944
969
|
// api-error, completed — and what it cost (0 means "not reported", never "free"). #7762: `card`
|
|
945
970
|
// binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
|
|
@@ -952,6 +977,7 @@ exit $turn_exit`;
|
|
|
952
977
|
try { finalOutcome = (await opts.judgeOutcome()) || outcome; } catch {}
|
|
953
978
|
}
|
|
954
979
|
const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, card: sessionCard || 0, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
|
|
980
|
+
if (usage) telemetryRow.usage = usage;
|
|
955
981
|
if (cut) telemetryRow.cut = true;
|
|
956
982
|
if (stallCut) telemetryRow.stalled = true;
|
|
957
983
|
if (extensions) { telemetryRow.extensions = extensions; telemetryRow.boxMs = boxMs; }
|
|
@@ -967,7 +993,10 @@ exit $turn_exit`;
|
|
|
967
993
|
if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
|
|
968
994
|
// #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
|
|
969
995
|
// pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
|
|
970
|
-
if (realExit === 0 && effExit === 0)
|
|
996
|
+
if (realExit === 0 && effExit === 0) {
|
|
997
|
+
writeTurnState(AGENT, PROJ, { phase: "idle", since: Date.now() });
|
|
998
|
+
await registerStatus("idle", { phase: "idle", phaseSince: Date.now() });
|
|
999
|
+
}
|
|
971
1000
|
// The follow-up rides the SAME session, exactly once. A state step gets none: TIME_BOX_PROMPT
|
|
972
1001
|
// would break the byte-identical prefix, and §4.4 says a cut turn never partially applied a patch.
|
|
973
1002
|
if (cut && !inFollowUp && !opts.state) {
|
|
@@ -1055,24 +1084,10 @@ async function loadLessons() {
|
|
|
1055
1084
|
// #5683: every prompt section is capped (bin/crew-payload.mjs) and the payload has ONE hard total
|
|
1056
1085
|
// cap; below the caps the composition is byte-identical to the old concatenation.
|
|
1057
1086
|
function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "", tailText = "", rulesText = "", lessons = null }) {
|
|
1058
|
-
// STABLE FIRST, VOLATILE LAST —
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
// hit bills at roughly a tenth of a miss. This array used to open with `base` and then the
|
|
1063
|
-
// per-turn WAKE, with the big unchanging blocks (RULES, lessons) buried at the end. On an ordinary
|
|
1064
|
-
// turn `base` is empty — the sha rides inside `again` — so token 0 was the wake text itself, which
|
|
1065
|
-
// is different every turn by definition. No cache could ever hit, and ~4,800 tokens of identical preamble were re-bought at full rate per turn,
|
|
1066
|
-
// on every foreign-CLI seat: dsh, codex, kimi and every opencode/BYOM seat. The state path had
|
|
1067
|
-
// known this for weeks — lib/state/assemble.mjs: "the bytes before STATE_DELIM are the caller's
|
|
1068
|
-
// preamble and NOTHING else, or prefix caching never engages" — and it was never carried across.
|
|
1069
|
-
//
|
|
1070
|
-
// Reading order improves with it rather than suffering: rules and lessons are the standing brief,
|
|
1071
|
-
// then context and history, and the ASK lands last, which is what #7063 found a work order wants.
|
|
1072
|
-
//
|
|
1073
|
-
// The `order` field is UNRELATED to this and deliberately unchanged: it ranks what gets trimmed
|
|
1074
|
-
// when the payload busts its total cap, and wake is trimmed last there because losing the
|
|
1075
|
-
// instruction is worse than losing the cache on that one turn.
|
|
1087
|
+
// STABLE FIRST, VOLATILE LAST — a COST decision, not style (#8199): providers cache on an exact
|
|
1088
|
+
// prefix from token 0, and the old order opened with the per-turn wake, so no cache could ever
|
|
1089
|
+
// hit and ~4,800 identical preamble tokens re-bought at full rate per turn on every seat. The
|
|
1090
|
+
// `order` field is the unrelated trim ranking under the total cap (#5683).
|
|
1076
1091
|
const built = composePrompt([
|
|
1077
1092
|
{ name: "rules", text: rulesText, trim: "drop", order: 3 },
|
|
1078
1093
|
{ name: "lessons", text: lessons?.text || "", trim: "drop", order: 2 },
|
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, fmtSpend } from "../lib/seat-why.mjs";
|
|
4
|
+
import { seatWhy, fmtSpend, dur } from "../lib/seat-why.mjs";
|
|
5
5
|
import { resolveProject } from "../lib/project.mjs";
|
|
6
6
|
|
|
7
7
|
const args = process.argv.slice(2);
|
|
@@ -22,6 +22,12 @@ if (asJson) {
|
|
|
22
22
|
console.log(JSON.stringify({ agent, project, ...out }, null, 2));
|
|
23
23
|
} else {
|
|
24
24
|
console.log(`seat ${agent}:${project} -> ${out.state}`);
|
|
25
|
+
// #7749: the live phase line — what the seat is doing NOW and for how long, from the
|
|
26
|
+
// turn-state file, not inferred from the last ledger row.
|
|
27
|
+
if (out.turnState?.phase) {
|
|
28
|
+
const t = out.turnState;
|
|
29
|
+
console.log(`turn: ${t.phase} for ${dur(Date.now() - t.since)} (turn ${t.turn}${t.card ? `, card #${t.card}` : ""})`);
|
|
30
|
+
}
|
|
25
31
|
console.log(`why: ${out.why}`);
|
|
26
32
|
console.log(`today: ${fmtSpend(out.today)}`);
|
|
27
33
|
console.log(`advice: ${out.advice}`);
|
package/bin/seats.mjs
CHANGED
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// trantor seats — declare which project lives in which directory, see which ones are missing, and
|
|
3
|
-
// put them back
|
|
4
|
-
//
|
|
5
|
-
// trantor seats status of every declared seat
|
|
6
|
-
// trantor seats add <project> [dir] declare one (dir defaults to the cwd)
|
|
7
|
-
// trantor seats remove <project> undeclare one
|
|
8
|
-
// trantor seats adopt [workspace] declare every pinned project found under a workspace root
|
|
9
|
-
// trantor seats up [project…] open a window for each MISSING seat, in its own directory
|
|
10
|
-
// trantor seats login install bring missing seats back automatically after a reboot
|
|
11
|
-
// trantor seats login uninstall|status
|
|
3
|
+
// put them back after a reboot that reopened every window in $HOME. Run with no arguments for the
|
|
4
|
+
// full command list.
|
|
12
5
|
import { existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
13
6
|
import { join, resolve } from "node:path";
|
|
14
7
|
import { homedir } from "node:os";
|
|
15
8
|
import { execFileSync } from "node:child_process";
|
|
16
9
|
import {
|
|
17
|
-
readSeats, declareSeat, undeclareSeat, seatStatus,
|
|
18
|
-
launchSeat, suggestSeats, projectForDir,
|
|
10
|
+
readSeats, declareSeat, undeclareSeat, seatStatus,
|
|
11
|
+
launchSeat, suggestSeats, projectForDir, waitForHerdr,
|
|
19
12
|
} from "../lib/seats.mjs";
|
|
20
13
|
|
|
21
14
|
const args = process.argv.slice(2);
|
|
@@ -38,19 +31,24 @@ function printStatus() {
|
|
|
38
31
|
const w = Math.max(...rows.map(r => r.project.length), 7);
|
|
39
32
|
console.log(`${D}seat${" ".repeat(Math.max(0, w - 4))} status directory${R}`);
|
|
40
33
|
for (const r of rows) {
|
|
41
|
-
const status = r.live ? `${G}live${R} ${D}(${r.agent} ${r.pid})${R}`
|
|
34
|
+
const status = r.live ? `${G}live${R} ${D}(${r.agent} ${r.pid !== null ? r.pid : `pane ${r.pane}`})${R}`
|
|
35
|
+
: r.state === "unknown" ? `${O}UNKNOWN${R}`
|
|
42
36
|
: r.exists ? `${O}MISSING${R}` : `${O}NO DIR${R}`;
|
|
43
37
|
// (why is printed below for anything not live)
|
|
44
38
|
const pad = " ".repeat(Math.max(0, w - r.project.length));
|
|
45
|
-
console.log(`${r.project}${pad} ${status}${" ".repeat(Math.max(1, 20 - (r.live ? 4 + String(r.pid).length + r.agent.length + 4 : 7)))}${D}${r.dir}${R}`);
|
|
39
|
+
console.log(`${r.project}${pad} ${status}${" ".repeat(Math.max(1, 20 - (r.live ? 4 + String(r.pid !== null ? r.pid : `pane ${r.pane}`).length + r.agent.length + 4 : 7)))}${D}${r.dir}${R}`);
|
|
46
40
|
if (!r.live && r.why) console.log(`${" ".repeat(w + 2)}${D}${r.why}${R}`);
|
|
47
41
|
if (r.via !== "pin") console.log(`${" ".repeat(w + 2)}${O}⚠ hub not pinned${R} ${D}— resolves to ${r.hub} via ${r.via}; pin it: trantor hub set ${r.project} <url>${R}`);
|
|
48
42
|
}
|
|
49
|
-
const miss = rows.filter(r =>
|
|
43
|
+
const miss = rows.filter(r => r.state === "missing" && r.exists);
|
|
44
|
+
const unk = rows.filter(r => r.state === "unknown");
|
|
50
45
|
console.log("");
|
|
51
46
|
if (miss.length) {
|
|
52
47
|
console.log(`${O}${miss.length} seat(s) not running${R}: ${miss.map(m => m.project).join(", ")}`);
|
|
53
48
|
console.log(`Bring them back: ${O}trantor seats up${R}`);
|
|
49
|
+
} else if (unk.length) {
|
|
50
|
+
console.log(`${O}${unk.length} seat(s) in an unknown state${R}: ${unk.map(u => u.project).join(", ")}`);
|
|
51
|
+
console.log(`${D}Unknown is not missing — nothing is launched for them until the state is provable.${R}`);
|
|
54
52
|
} else {
|
|
55
53
|
console.log(`${G}every declared seat is live${R}`);
|
|
56
54
|
}
|
|
@@ -63,9 +61,8 @@ function trantorBin() {
|
|
|
63
61
|
}
|
|
64
62
|
|
|
65
63
|
// One-shot login job. Deliberately NOT KeepAlive: a KeepAlive job whose command fails relaunches
|
|
66
|
-
// every ThrottleInterval forever
|
|
67
|
-
//
|
|
68
|
-
// the desktop settle, and exits.
|
|
64
|
+
// every ThrottleInterval forever (it once drove this machine to load 490, four portless services
|
|
65
|
+
// rebuilding every 10s). It runs once, after a delay that lets the desktop settle, and exits.
|
|
69
66
|
function plistBody(delay) {
|
|
70
67
|
const bin = trantorBin();
|
|
71
68
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -129,28 +126,37 @@ switch (sub) {
|
|
|
129
126
|
|
|
130
127
|
case "up": {
|
|
131
128
|
const only = rest;
|
|
132
|
-
let miss = missingSeats();
|
|
133
|
-
if (only.length) miss = miss.filter(m => only.includes(m.project));
|
|
134
129
|
const login = flag("login");
|
|
130
|
+
// #8716: the login job races herdr's pane restore — a seat judged missing before its pane is
|
|
131
|
+
// back gets launched as a second Terminal window. Wait bounded, then never launch on UNKNOWN.
|
|
132
|
+
const herdr = login ? await waitForHerdr() : undefined;
|
|
133
|
+
if (herdr && !herdr.ok) {
|
|
134
|
+
console.log(`[${new Date().toISOString()}] herdr is installed but did not answer within 120s — its panes may still be restoring, so no seat can be judged missing; launching nothing`);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
const rows = seatStatus(undefined, herdr).filter(r => !only.length || only.includes(r.project));
|
|
138
|
+
const unknowns = rows.filter(r => r.state === "unknown");
|
|
139
|
+
const miss = rows.filter(r => r.state === "missing" && r.exists);
|
|
140
|
+
const when = login ? `[${new Date().toISOString()}] ` : "";
|
|
141
|
+
for (const u of unknowns) console.log(`${when}${O}unknown, not launching${R}: ${u.project} — ${u.why}`);
|
|
135
142
|
if (!miss.length) {
|
|
136
|
-
if (!login) console.log(`${G}nothing to do — every declared seat is live${R}`);
|
|
137
|
-
else console.log(`
|
|
143
|
+
if (!login) console.log(`${G}nothing to do — every declared seat is live or unknown${R}`);
|
|
144
|
+
else console.log(`${when}nothing launched${unknowns.length ? ` — ${unknowns.length} seat(s) unknown, see above` : "; every declared seat is live"}`);
|
|
138
145
|
break;
|
|
139
146
|
}
|
|
140
|
-
// A guard rail, not a policy:
|
|
141
|
-
//
|
|
147
|
+
// A guard rail, not a policy: restoring a dozen seats at once is never what anyone meant, and
|
|
148
|
+
// at login it would be actively hostile.
|
|
142
149
|
const CAP = 6;
|
|
143
150
|
if (miss.length > CAP && !flag("force")) {
|
|
144
|
-
console.error(`${O}${miss.length} seats are missing — refusing to
|
|
151
|
+
console.error(`${O}${miss.length} seats are missing — refusing to restore that many at once.${R}`);
|
|
145
152
|
console.error(`Name the ones you want (trantor seats up <project>…), or pass --force.`);
|
|
146
153
|
process.exit(1);
|
|
147
154
|
}
|
|
148
155
|
for (const m of miss) {
|
|
149
156
|
const r = launchSeat(m, { dryRun: flag("dry-run") });
|
|
150
|
-
|
|
151
|
-
if (r.launched) console.log(`${when}${G}opened${R} ${m.project} — ${m.dir}`);
|
|
157
|
+
if (r.launched) console.log(`${when}${G}restored${R} ${m.project} — ${m.dir}`);
|
|
152
158
|
else if (flag("dry-run")) console.log(`${when}would run: ${r.command}`);
|
|
153
|
-
else console.log(`${when}${O}
|
|
159
|
+
else console.log(`${when}${O}not launched${R} for ${m.project} — run it yourself: ${r.command}${r.error ? ` (${r.error})` : ""}`);
|
|
154
160
|
}
|
|
155
161
|
break;
|
|
156
162
|
}
|
|
@@ -162,11 +168,10 @@ switch (sub) {
|
|
|
162
168
|
const delay = Number(rest[1] || 60);
|
|
163
169
|
writeFileSync(PLIST, plistBody(Number.isFinite(delay) && delay >= 0 ? delay : 60));
|
|
164
170
|
try { execFileSync("/bin/launchctl", ["bootout", `gui/${process.getuid()}/${LABEL}`], { stdio: "ignore" }); } catch {}
|
|
165
|
-
// Deliberately NOT bootstrapped here
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// launchd loads it at the next login. --now is the explicit opt-in for right this second.
|
|
171
|
+
// Deliberately NOT bootstrapped here: RunAtLoad fires on bootstrap, and installing a RECOVERY
|
|
172
|
+
// job must never immediately reopen the very sessions the operator is holding closed (it once
|
|
173
|
+
// very nearly did). The plist on disk is the install; launchd loads it at the next login.
|
|
174
|
+
// --now is the explicit opt-in for right this second.
|
|
170
175
|
if (flag("now")) {
|
|
171
176
|
try {
|
|
172
177
|
execFileSync("/bin/launchctl", ["bootstrap", `gui/${process.getuid()}`, PLIST], { stdio: "ignore" });
|
|
@@ -204,12 +209,13 @@ switch (sub) {
|
|
|
204
209
|
add <project> [dir] declare a seat (dir defaults to cwd; --agent=claude|codex|opencode)
|
|
205
210
|
remove <project> undeclare a seat
|
|
206
211
|
adopt [workspace] --yes declare every pinned project that has a directory there
|
|
207
|
-
up [project…]
|
|
212
|
+
up [project…] restore each missing seat as a herdr pane, in its own directory
|
|
208
213
|
(--dry-run to see the commands, --force past the 6-seat cap)
|
|
209
214
|
login install [delay] reopen missing seats automatically after a reboot (--now to start it immediately)
|
|
210
215
|
login uninstall|status
|
|
211
216
|
|
|
212
217
|
Seats live in ~/.agent-bus/config.json alongside the hub pins. A seat is "live" when a real agent
|
|
213
|
-
process is standing in its directory — not when something on the hub claims
|
|
218
|
+
process (or its herdr pane) is standing in its directory — not when something on the hub claims
|
|
219
|
+
that name. A seat whose holder cannot be proven absent is UNKNOWN: recovery never launches into it.`);
|
|
214
220
|
process.exit(readSeats() ? 0 : 0);
|
|
215
221
|
}
|
package/bin/turn-watchdog.mjs
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
// Turn watchdog (#5684, #6206, #7752, #7761): runTurn is spawnSync, so this DETACHED helper watches
|
|
3
3
|
// the turn — liveness = transcript, worktree or stderr moving; a whole silent window earns ONE stall
|
|
4
4
|
// report, and in kill mode (stall file given) also ends the turn via the shell box. Stamp-bound to one runner.
|
|
5
|
-
// node bin/turn-watchdog.mjs <stampFile> <errFile> <windowMs> <session> <project> <hubUrl> <transcriptDir> <workDir> [stallFile]
|
|
5
|
+
// node bin/turn-watchdog.mjs <stampFile> <errFile> <windowMs> <session> <project> <hubUrl> <transcriptDir> <workDir> [stallFile] [turnStateFile]
|
|
6
6
|
import { readFileSync, writeFileSync, appendFileSync, statSync, readdirSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { hostId } from "../lib/project.mjs";
|
|
9
9
|
import { signedPost } from "../hooks/lib/api.mjs";
|
|
10
|
+
import { refreshTurnLiveness } from "../lib/turnstate.mjs";
|
|
10
11
|
|
|
11
|
-
const [stampFile, errFile, windowMsRaw, session, project, hub, transcriptDir = "", workDir = "", stallFile = ""] = process.argv.slice(2);
|
|
12
|
+
const [stampFile, errFile, windowMsRaw, session, project, hub, transcriptDir = "", workDir = "", stallFile = "", turnStateFile = ""] = process.argv.slice(2);
|
|
12
13
|
// SAFETY: the 10-minute floor lives in crew-runner.mjs (the default when TRANTOR_TURN_WATCHDOG_MS
|
|
13
14
|
// is unset); this fallback only covers a missing argument. Drills pass tiny windows on purpose.
|
|
14
15
|
const windowMs = Number(windowMsRaw) || 10 * 60 * 1000;
|
|
@@ -61,6 +62,21 @@ let baseErr = errSize();
|
|
|
61
62
|
const armedAt = armed.startedAt || Date.now();
|
|
62
63
|
const SLACK = 2000; // timestamp granularity + scheduler drift under load
|
|
63
64
|
|
|
65
|
+
// #7749: mid-turn liveness lands in the seat's turn-state file — any new stderr byte moves
|
|
66
|
+
// lastBytesAt, a newer transcript file moves lastTranscriptAt. The refresh never writes phase
|
|
67
|
+
// (the runner owns transitions) and refuses a file whose turn or phase has moved on.
|
|
68
|
+
let prevSize = baseErr;
|
|
69
|
+
let prevTr = 0;
|
|
70
|
+
function reportLiveness(now, tr) {
|
|
71
|
+
if (!turnStateFile) return;
|
|
72
|
+
const size = errSize();
|
|
73
|
+
const grew = size > prevSize;
|
|
74
|
+
prevSize = size;
|
|
75
|
+
const trGrew = tr > prevTr;
|
|
76
|
+
if (trGrew) prevTr = tr;
|
|
77
|
+
if (grew || trGrew) refreshTurnLiveness(turnStateFile, { turn: armed.turn, bytesAt: grew ? now : 0, transcriptAt: trGrew ? tr : 0 });
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
// #7752 kill mode: poll liveness; a whole window silent on EVERY channel writes the stall marker
|
|
65
81
|
// (the shell box sweeps on it), reports once, exits; stderr is measured ROLLING (bytes this window).
|
|
66
82
|
// #7761: `armed.box` (step, ceiling, assigners) exists only when a ceiling above the box does; a
|
|
@@ -95,6 +111,7 @@ if (stallFile) {
|
|
|
95
111
|
const freshCut = Math.max(armedAt, now - windowMs - SLACK);
|
|
96
112
|
const tr = transcriptDir ? newestMtime(transcriptDir) : 0;
|
|
97
113
|
const wk = workDir ? newestMtime(workDir) : 0;
|
|
114
|
+
reportLiveness(now, tr);
|
|
98
115
|
if (tr > freshCut || wk > freshCut || now - lastErrAt < windowMs) { // producing work: alive
|
|
99
116
|
if (box && extensions < Number(box.extensionsMax) && now + poll + SLACK >= deadline) {
|
|
100
117
|
extensions++;
|
|
@@ -129,6 +146,7 @@ for (;;) {
|
|
|
129
146
|
const freshCut = Math.max(armedAt, Date.now() - windowMs - SLACK);
|
|
130
147
|
const tr = transcriptDir ? newestMtime(transcriptDir) : 0;
|
|
131
148
|
const wk = workDir ? newestMtime(workDir) : 0;
|
|
149
|
+
reportLiveness(Date.now(), tr);
|
|
132
150
|
if (errSize() > baseErr + 200 || tr > freshCut || wk > freshCut) continue; // producing work: alive, re-arm
|
|
133
151
|
const mins = Math.round((Date.now() - armedAt) / 60000);
|
|
134
152
|
const orch = `${hostId()}:${project}`;
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -888,6 +888,14 @@ export function pathsReadIn(transcriptPath, paths) {
|
|
|
888
888
|
return { read, missed: want.filter(p => !read.includes(p)) };
|
|
889
889
|
}
|
|
890
890
|
|
|
891
|
+
/** Markdown on stdin, or a bare `trantor handoff`? A heredoc is a TEMP FILE, not a FIFO, and an
|
|
892
|
+
* isFIFO()-only check discarded the model's handoff while printing "handoff armed" (#8459).
|
|
893
|
+
* Character devices stay out: that is the TTY and the /dev/null a hook runs with.
|
|
894
|
+
*/
|
|
895
|
+
export function stdinCarriesMarkdown(fd = 0) {
|
|
896
|
+
try { const st = fstatSync(fd); return st.isFIFO() || st.isFile(); } catch { return false; }
|
|
897
|
+
}
|
|
898
|
+
|
|
891
899
|
// The self-announcing fresh session command (single-quoted so it survives osascript→shell un-escaped).
|
|
892
900
|
// Brevity is the point, but #8162 found it had become permission not to READ: the summary is injected
|
|
893
901
|
// at SessionStart, so a 3-sentence recap is producible without opening a file. The order is now
|
package/hub/routes/admin.mjs
CHANGED
|
@@ -26,6 +26,9 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
26
26
|
// #6148: WHAT a session is rides its peer row (kind "genesis" = the CLI's brief-poster,
|
|
27
27
|
// "agent" = a crew seat) — /peers hands it to the app so the seat strip can tell them apart.
|
|
28
28
|
if (pr) { if (b.model) pr.model = String(b.model).slice(0, 80); if (b.llm) pr.llm = String(b.llm).slice(0, 40); if (b.kind) pr.kind = String(b.kind).slice(0, 40); }
|
|
29
|
+
// #7749: the runner's live turn phase (working/idle/cut/stalled/parked) + its start ride the
|
|
30
|
+
// presence row, so /peers answers "what is the seat doing now" without inferring it from durations.
|
|
31
|
+
if (pr && b.phase) { pr.phase = String(b.phase).slice(0, 20); pr.phaseSince = Number(b.phaseSince) || now(); }
|
|
29
32
|
return json(res, 200, { ok: true, session: b.session, peers: Object.keys(state.peers) });
|
|
30
33
|
}
|
|
31
34
|
if (req.method === "POST" && P === "/status") { const b = await body(req); touch(b.session, b.status ?? "", b.project, b.hookVersion, auth); return json(res, 200, { ok: true }); }
|
|
@@ -316,7 +319,7 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
316
319
|
prunePeers();
|
|
317
320
|
const cutoff = now() - ONLINE_MS;
|
|
318
321
|
const peerRows = filterDiscoverable(auth, Object.entries(state.peers), ([, v]) => v.project || "");
|
|
319
|
-
return json(res, 200, { hubVersion: HUB_VERSION, authMode: AUTH_MODE, peers: peerRows.map(([s, v]) => ({ session: s, lastSeen: v.lastSeen, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status), project: v.project || "",
|
|
322
|
+
return json(res, 200, { hubVersion: HUB_VERSION, authMode: AUTH_MODE, peers: peerRows.map(([s, v]) => ({ session: s, lastSeen: v.lastSeen, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status), project: v.project || "", phase: v.phase || "", phaseSince: v.phaseSince || 0,
|
|
320
323
|
pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "",
|
|
321
324
|
kind: v.kind || v.identity?.kind || "", llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) })) });
|
|
322
325
|
}
|
package/lib/seat-why.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { execSync } from "node:child_process";
|
|
7
7
|
import { asNumber, asRecord } from "./decode.mjs";
|
|
8
|
+
import { readTurnState } from "./turnstate.mjs";
|
|
8
9
|
|
|
9
10
|
const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
10
11
|
|
|
@@ -73,6 +74,29 @@ const rel = (ts) => {
|
|
|
73
74
|
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
74
75
|
return `${Math.round(s / 3600)}h ago`;
|
|
75
76
|
};
|
|
77
|
+
// A duration ("working for 4m"), the counterpart to rel's "4m ago".
|
|
78
|
+
const dur = (ms) => {
|
|
79
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
80
|
+
if (s < 60) return `${s}s`;
|
|
81
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
82
|
+
return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// #7749: when the turn-state file exists the advice IS the phase — "watch it or swap" is what you
|
|
86
|
+
// say when a turn cannot be seen; now it can. Returns null without a phase so the caller falls
|
|
87
|
+
// back to the last-ledger-row reading.
|
|
88
|
+
function phaseAdvice(t) {
|
|
89
|
+
if (!t?.phase || !t.since) return null;
|
|
90
|
+
const on = t.card ? ` on #${t.card}` : "";
|
|
91
|
+
switch (t.phase) {
|
|
92
|
+
case "working": return `working for ${dur(Date.now() - t.since)}${on} — the turn is live; nothing to do.`;
|
|
93
|
+
case "idle": return `idle since ${fmt(t.since)} — waiting for the next message; nothing to do.`;
|
|
94
|
+
case "cut": return `cut at the time box at ${fmt(t.since)}${on} — the runner continues in a follow-up turn; swap only if the cuts repeat.`;
|
|
95
|
+
case "stalled": return `stalled since ${fmt(t.since)}${on} — the watchdog cut a silent turn; watch the pane if the next turn stalls too.`;
|
|
96
|
+
case "parked": return `parked since ${fmt(t.since)} — it holds its queue until the reset time or \`trantor up\`; the park reason is on the bus.`;
|
|
97
|
+
default: return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
76
100
|
|
|
77
101
|
// What this seat has SPENT today (#6134). The turn count is the number that mattered on 09-02:
|
|
78
102
|
// 151 turns and ~16 agentic hours across the fleet, most of it redelivery and coordination noise.
|
|
@@ -104,6 +128,8 @@ export function seatWhy(project, agent, opts = {}) {
|
|
|
104
128
|
const dir = opts.dir || busDir();
|
|
105
129
|
const errText = readF(join(dir, `err-${agent}-${project}.txt`));
|
|
106
130
|
const telemetry = readTelemetry(join(dir, "logs", `${agent}-${project}.jsonl`));
|
|
131
|
+
// #7749: the runner's LIVE turn state — written at every transition, refreshed mid-turn.
|
|
132
|
+
const turnState = readTurnState(agent, project, dir);
|
|
107
133
|
const pane = findPane(dir, project, agent);
|
|
108
134
|
const pids = opts.pidCheck ? opts.pidCheck(project, agent) : scanPids(project, agent);
|
|
109
135
|
const last = [...telemetry].reverse().find(r => asNumber(r.turn) !== null) || null;
|
|
@@ -132,16 +158,24 @@ export function seatWhy(project, agent, opts = {}) {
|
|
|
132
158
|
why = `crew-runner pid ${pidList} alive for ${agent}:${project}, but crew-windows.txt has no pane row — the seat is headless.`;
|
|
133
159
|
advice = "give the seat a window it can be read in: `trantor up <agent>` (or attach via herdr/cmux).";
|
|
134
160
|
} else {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
:
|
|
161
|
+
// #7749: the live phase beats the ledger row — a seat mid-turn reads "working for 4m" with
|
|
162
|
+
// its last-output age, not "last turn ended N minutes ago" (which a dead seat also says).
|
|
163
|
+
const health = turnState?.phase
|
|
164
|
+
? `turn ${turnState.turn} ${turnState.phase} for ${dur(Date.now() - turnState.since)}${turnState.card ? ` (card #${turnState.card})` : ""}${turnState.phase === "working" && turnState.lastBytesAt ? `, last output ${rel(turnState.lastBytesAt)}` : ""}`
|
|
165
|
+
: last
|
|
166
|
+
? (last.effExit || last.exit)
|
|
167
|
+
? `last turn ${rel(last.ts)} (${last.trigger}) FAILED (exit ${last.effExit ?? last.exit}${last.authFailed ? ", auth" : ""})`
|
|
168
|
+
: `last turn ${rel(last.ts)} (${last.trigger}) clean (exit 0)`
|
|
169
|
+
: `no completed turn yet (${boots} boot record${boots === 1 ? "" : "s"})`;
|
|
140
170
|
state = "live";
|
|
141
171
|
why = `crew-runner pid ${pidList} alive, pane ${pane.source}/${pane.pane}; ${health}.`;
|
|
142
|
-
advice
|
|
143
|
-
|
|
144
|
-
|
|
172
|
+
// #7749: a live phase replaces the generic advice — a seat mid-turn is not "watch it or
|
|
173
|
+
// swap", it is "working for 4m on #7749". The ledger fallback covers a seat whose runner
|
|
174
|
+
// predates the turn-state file.
|
|
175
|
+
advice = phaseAdvice(turnState)
|
|
176
|
+
|| (last && (last.effExit || last.exit)
|
|
177
|
+
? `seat is up but its last turn failed${last.authFailed ? " — auth: check the provider key" : ""}. Watch it or swap.`
|
|
178
|
+
: "seat is healthy — nothing to do.");
|
|
145
179
|
}
|
|
146
180
|
} else {
|
|
147
181
|
const bits = [];
|
|
@@ -169,7 +203,7 @@ export function seatWhy(project, agent, opts = {}) {
|
|
|
169
203
|
}
|
|
170
204
|
}
|
|
171
205
|
|
|
172
|
-
return { state, why, advice, today: todaySpend(telemetry, opts.now) };
|
|
206
|
+
return { state, why, advice, today: todaySpend(telemetry, opts.now), turnState: turnState || null };
|
|
173
207
|
}
|
|
174
208
|
|
|
175
|
-
export { fmt, rel };
|
|
209
|
+
export { fmt, rel, dur };
|
package/lib/seats.mjs
CHANGED
|
@@ -47,7 +47,8 @@ export function undeclareSeat(project) {
|
|
|
47
47
|
|
|
48
48
|
// ── liveness ────────────────────────────────────────────────────────────────────
|
|
49
49
|
// A seat is live when a real process is STANDING IN ITS DIRECTORY (ps + lsof, bounded, fail-soft),
|
|
50
|
-
// never by the hub's presence list
|
|
50
|
+
// never by the hub's presence list. Two blind spots read as UNKNOWN, never missing (#8716): a
|
|
51
|
+
// process whose cwd lsof could not read could be standing in any seat, and so could a dark herdr.
|
|
51
52
|
export function liveAgentDirs(binaries = ["claude", "codex", "opencode"]) {
|
|
52
53
|
const out = []; // [{ pid, comm, cwd }]
|
|
53
54
|
let rows = "";
|
|
@@ -62,21 +63,58 @@ export function liveAgentDirs(binaries = ["claude", "codex", "opencode"]) {
|
|
|
62
63
|
if (pid === self) continue;
|
|
63
64
|
const base = comm.split("/").pop();
|
|
64
65
|
if (!binaries.includes(base)) continue;
|
|
65
|
-
let cwd =
|
|
66
|
+
let cwd = null;
|
|
66
67
|
try {
|
|
67
68
|
const fn = execFileSync("/usr/sbin/lsof", ["-a", "-p", pid, "-d", "cwd", "-Fn"],
|
|
68
69
|
{ encoding: "utf8", timeout: 4000, stdio: ["ignore", "pipe", "ignore"] });
|
|
69
70
|
const hit = fn.split("\n").find(l => l.startsWith("n"));
|
|
70
71
|
if (hit) cwd = hit.slice(1);
|
|
71
|
-
} catch { /* process died, or lsof
|
|
72
|
-
|
|
72
|
+
} catch { /* the process died, or lsof starved: the cwd is UNKNOWN, never live, never absent */ }
|
|
73
|
+
out.push({ pid: Number(pid), comm: base, cwd });
|
|
73
74
|
}
|
|
74
75
|
return out;
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
78
|
+
// herdr restores its panes at boot, before any Terminal window could exist, so a herdr agent
|
|
79
|
+
// standing in the seat's directory holds the seat exactly as a bare process would (#8716).
|
|
80
|
+
// Installed but unreachable reads { installed: true, ok: false } — uncertainty, not absence; not
|
|
81
|
+
// installed is SKIP: the probe holds nothing and blinds nothing.
|
|
82
|
+
export function herdrAgents(exec = execFileSync) {
|
|
83
|
+
let bin = "";
|
|
84
|
+
try { bin = exec("/usr/bin/which", ["herdr"], { encoding: "utf8", timeout: 4000 }).trim(); } catch { return { installed: false, ok: true, agents: [] }; }
|
|
85
|
+
if (!bin) return { installed: false, ok: true, agents: [] };
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(exec(bin, ["agent", "list"], { encoding: "utf8", timeout: 4000, stdio: ["ignore", "pipe", "ignore"] }));
|
|
88
|
+
const rows = asRecord(parsed?.result)?.agents;
|
|
89
|
+
if (!Array.isArray(rows)) return { installed: true, ok: false, agents: [] };
|
|
90
|
+
const agents = rows.map(a => ({
|
|
91
|
+
agent: asString(asRecord(a)?.agent) ?? "",
|
|
92
|
+
cwd: asString(asRecord(a)?.cwd) ?? "",
|
|
93
|
+
pane: asString(asRecord(a)?.pane_id) ?? "",
|
|
94
|
+
status: asString(asRecord(a)?.agent_status) ?? "",
|
|
95
|
+
})).filter(a => a.agent && a.cwd);
|
|
96
|
+
return { installed: true, ok: true, agents };
|
|
97
|
+
} catch { return { installed: true, ok: false, agents: [] }; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The login job races herdr's restore: panes land over the first seconds after boot, and a seat
|
|
101
|
+
// judged missing before its pane returns is launched twice. Bounded: herdr that never answers
|
|
102
|
+
// inside the window reads { installed: true, ok: false }, and the caller launches nothing (#8716).
|
|
103
|
+
export async function waitForHerdr({ timeoutMs = 120000, everyMs = 5000, probe = herdrAgents } = {}) {
|
|
104
|
+
const deadline = Date.now() + timeoutMs;
|
|
105
|
+
let h = probe();
|
|
106
|
+
while (h.installed && !h.ok && Date.now() < deadline) {
|
|
107
|
+
await new Promise(r => setTimeout(r, everyMs));
|
|
108
|
+
h = probe();
|
|
109
|
+
}
|
|
110
|
+
return h;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Status of every declared seat: live (held by a process or a herdr pane standing in its
|
|
114
|
+
// directory), unknown (a blind spot could be hiding the holder — launched over by nothing), or
|
|
115
|
+
// missing (with why). `live` and `herdr` are injectable so a drill pins the mapping without
|
|
116
|
+
// spawning processes named "claude" or a real herdr.
|
|
117
|
+
export function seatStatus(live = liveAgentDirs(), herdr = herdrAgents()) {
|
|
80
118
|
const seats = readSeats();
|
|
81
119
|
return Object.entries(seats).map(([project, { dir, agent }]) => {
|
|
82
120
|
// The seat is held only by the agent it was DECLARED for. A crew seat (opencode, codex) working
|
|
@@ -87,34 +125,67 @@ export function seatStatus(live = liveAgentDirs()) {
|
|
|
87
125
|
const holder = inDir.find(p => p.comm === agent);
|
|
88
126
|
const others = inDir.filter(p => p.comm !== agent);
|
|
89
127
|
const exists = existsSync(dir);
|
|
128
|
+
let pane = null;
|
|
129
|
+
if (herdr.ok) {
|
|
130
|
+
for (const a of herdr.agents) {
|
|
131
|
+
if (a.cwd !== dir) continue;
|
|
132
|
+
if (a.agent === agent) { if (!holder) pane = a.pane || "herdr pane"; }
|
|
133
|
+
else others.push({ pid: null, comm: a.agent, cwd: dir, pane: a.pane });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// UNKNOWN (#8716): an agent process whose cwd lsof could not read could be standing in this
|
|
137
|
+
// directory, and a herdr that is installed but would not answer could be holding this seat.
|
|
138
|
+
// Either way the holder is unprovable in both directions — the seat is not live, and it is
|
|
139
|
+
// never missing, because launching into an occupied seat is the harm this file prevents.
|
|
140
|
+
const unreadable = live.filter(p => p.cwd === null && p.comm === agent);
|
|
141
|
+
const herdrDark = herdr.installed && !herdr.ok;
|
|
142
|
+
const unknown = exists && !holder && !pane && (unreadable.length > 0 || herdrDark);
|
|
143
|
+
const why = holder || pane ? ""
|
|
144
|
+
: !exists ? "directory does not exist"
|
|
145
|
+
: unknown ? (unreadable.length
|
|
146
|
+
? `lsof could not read the cwd of ${unreadable.map(p => `${agent} ${p.pid}`).join(", ")} — it could be this seat`
|
|
147
|
+
: "herdr is installed but would not answer — a restored pane could be standing here")
|
|
148
|
+
: others.length ? `no ${agent} here (${others.map(o => o.pane ? `${o.comm} pane ${o.pane}` : `${o.comm} ${o.pid}`).join(", ")} is, but that is not this seat)`
|
|
149
|
+
: `no ${agent} process in this directory`;
|
|
90
150
|
return {
|
|
91
151
|
project, dir, exists, agent,
|
|
92
|
-
live: !!holder,
|
|
152
|
+
live: !!holder || !!pane,
|
|
153
|
+
state: holder || pane ? "live" : unknown ? "unknown" : "missing",
|
|
93
154
|
pid: holder?.pid || null,
|
|
155
|
+
pane,
|
|
94
156
|
others,
|
|
95
157
|
hub: resolveHubInfo(project).url,
|
|
96
158
|
via: resolveHubInfo(project).via,
|
|
97
|
-
why
|
|
98
|
-
: !exists ? "directory does not exist"
|
|
99
|
-
: others.length ? `no ${agent} here (${others.map(o => `${o.comm} ${o.pid}`).join(", ")} is, but that is not this seat)`
|
|
100
|
-
: `no ${agent} process in this directory`,
|
|
159
|
+
why,
|
|
101
160
|
};
|
|
102
161
|
}).sort((a, b) => a.project.localeCompare(b.project));
|
|
103
162
|
}
|
|
104
163
|
|
|
105
|
-
// Seats
|
|
106
|
-
|
|
164
|
+
// Seats a recovery may LAUNCH: declared, directory present, and provably held by nothing. An
|
|
165
|
+
// unknown seat is never here (#8716) — the one failure this file exists to prevent is opening a
|
|
166
|
+
// second window into a seat that is already held.
|
|
167
|
+
export function missingSeats(live, herdr) {
|
|
168
|
+
return seatStatus(live, herdr).filter(s => s.state === "missing" && s.exists);
|
|
169
|
+
}
|
|
107
170
|
|
|
108
171
|
// ── recovery ────────────────────────────────────────────────────────────────────
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
172
|
+
// Restore a missing seat as a herdr pane: `trantor open <project>` run in the seat's own
|
|
173
|
+
// directory — never a Terminal.app window, whose twin once ran for hours beside the seat it
|
|
174
|
+
// duplicated (#8716). Herdr absent: return the command for the caller to print, spawn nothing.
|
|
175
|
+
const SESSION_IDENTITY_ENV = ["TRANTOR_ORCH", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID", "CLAUDE_CODE_CHILD_SESSION", "CLAUDECODE"];
|
|
176
|
+
export function launchSeat(seat, { dryRun = false, spawnFn = spawn, env = process.env, which = execFileSync } = {}) {
|
|
177
|
+
let trantor = "trantor";
|
|
178
|
+
try { trantor = which("/usr/bin/which", ["trantor"], { encoding: "utf8", timeout: 4000 }).trim() || "trantor"; } catch {}
|
|
179
|
+
const cmd = `cd ${JSON.stringify(seat.dir)} && ${trantor} open ${seat.project}`;
|
|
180
|
+
let herdr = false;
|
|
181
|
+
try { herdr = !!which("/usr/bin/which", ["herdr"], { encoding: "utf8", timeout: 4000 }).trim(); } catch {}
|
|
182
|
+
if (dryRun || !herdr) return { launched: false, command: cmd };
|
|
183
|
+
// The recovery job may itself run inside a pane or a Claude session; its identity env must not
|
|
184
|
+
// leak into the restored one (#7414), and trantor open's own badge check would refuse it anyway.
|
|
185
|
+
const clean = { ...env };
|
|
186
|
+
for (const k of SESSION_IDENTITY_ENV) delete clean[k];
|
|
116
187
|
try {
|
|
117
|
-
const kid =
|
|
188
|
+
const kid = spawnFn(trantor, ["open", seat.project], { cwd: seat.dir, env: clean, detached: true, stdio: "ignore" });
|
|
118
189
|
kid.unref();
|
|
119
190
|
return { launched: true, command: cmd };
|
|
120
191
|
} catch (e) {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// #8234: seat turn rows carried tokens 0 for every CLI that prints no usage line (the opencode
|
|
2
|
+
// family, dsh) — a ledger that records that a turn happened, never what it cost. The counts live
|
|
3
|
+
// in each provider's OWN session record; read them there, never from stdout.
|
|
4
|
+
// Usage shape: { input, output, cacheRead, cacheWrite } — null means "could not read", never 0.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export function usageTotal(u) {
|
|
10
|
+
if (!u) return 0;
|
|
11
|
+
return (u.input || 0) + (u.output || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Absent = the provider did not report this field (0); present but not a non-negative integer =
|
|
15
|
+
// a corrupt read, which poisons the whole sum to null.
|
|
16
|
+
const field = (v) => {
|
|
17
|
+
if (v === "" || v === null || v === undefined) return 0;
|
|
18
|
+
const n = Number(v);
|
|
19
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
20
|
+
};
|
|
21
|
+
const blank = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
22
|
+
|
|
23
|
+
// ---- opencode: per-message usage in opencode.db, message.data JSON on assistant rows ----------
|
|
24
|
+
// The reader emits one `input|output|cacheRead|cacheWrite` row per in-window assistant message;
|
|
25
|
+
// this parser sums them. No rows at all = unknown, not zero.
|
|
26
|
+
export function sumOcRows(text) {
|
|
27
|
+
const rows = String(text || "").split("\n").filter((l) => l.trim() !== "");
|
|
28
|
+
if (!rows.length) return null;
|
|
29
|
+
const sum = blank();
|
|
30
|
+
for (const line of rows) {
|
|
31
|
+
const cols = line.split("|");
|
|
32
|
+
if (cols.length !== 4) return null;
|
|
33
|
+
const vals = cols.map(field);
|
|
34
|
+
if (vals.some((v) => v === null)) return null;
|
|
35
|
+
sum.input += vals[0]; sum.output += vals[1]; sum.cacheRead += vals[2]; sum.cacheWrite += vals[3];
|
|
36
|
+
}
|
|
37
|
+
return sum;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The one sqlite read, ocSid's mechanics exactly: sqlite3 CLI, -readonly, fail-open. Window is
|
|
41
|
+
// the turn (a resumed session's older messages carry older time_created values and drop out).
|
|
42
|
+
export function ocTurnUsage(dbPath, sessionId, sinceMs, untilMs, run = spawnSync) {
|
|
43
|
+
if (!dbPath || !/^ses_[A-Za-z0-9]+$/.test(String(sessionId || ""))) return null;
|
|
44
|
+
if (![sinceMs, untilMs].every(Number.isFinite) || untilMs < sinceMs) return null;
|
|
45
|
+
const q = `SELECT json_extract(data,'$.tokens.input'), json_extract(data,'$.tokens.output'),`
|
|
46
|
+
+ ` json_extract(data,'$.tokens.cache.read'), json_extract(data,'$.tokens.cache.write')`
|
|
47
|
+
+ ` FROM message WHERE session_id='${String(sessionId).replaceAll("'", "''")}'`
|
|
48
|
+
+ ` AND json_extract(data,'$.role')='assistant'`
|
|
49
|
+
+ ` AND time_created>=${Math.floor(sinceMs)} AND time_created<=${Math.ceil(untilMs)};`;
|
|
50
|
+
let r;
|
|
51
|
+
try { r = run("sqlite3", ["-readonly", dbPath, q], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }); }
|
|
52
|
+
catch { return null; }
|
|
53
|
+
if (!r || r.error || r.status !== 0) return null;
|
|
54
|
+
return sumOcRows(r.stdout);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- dsh: usage rides assistant/message events in session.jsonl.zstd ---------------------------
|
|
58
|
+
const USAGE_KEYS = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"];
|
|
59
|
+
// dsh logs every step's usage TWICE (assistant/chunk and assistant/message carry the same
|
|
60
|
+
// numbers), so only the assistant/message copy counts — the chunk copy would bill double. The
|
|
61
|
+
// session's own turn counter is unrelated to the runner's, so the time window is the only filter.
|
|
62
|
+
export function dshUsageFromEvent(ev) {
|
|
63
|
+
const u = ev && ev.type === "assistant/message" && ev.data ? ev.data.usage : null;
|
|
64
|
+
// A usage RECORD carries at least one of the four counts; anything else (null, an array, a
|
|
65
|
+
// bare number from a mangled log) is not one and drops out.
|
|
66
|
+
if (!u || !USAGE_KEYS.some((k) => Object.prototype.hasOwnProperty.call(u, k))) return null;
|
|
67
|
+
const g = (k) => { const n = Number(u[k]); return Number.isFinite(n) && n >= 0 ? n : 0; };
|
|
68
|
+
return { input: g("inputTokens"), output: g("outputTokens"), cacheRead: g("cacheReadTokens"), cacheWrite: g("cacheWriteTokens") };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function sumDshLog(text, sinceMs, untilMs) {
|
|
72
|
+
let sum = null;
|
|
73
|
+
for (const line of String(text || "").split("\n")) {
|
|
74
|
+
const s = line.trim();
|
|
75
|
+
if (!s) continue;
|
|
76
|
+
let ev; try { ev = JSON.parse(s); } catch { continue; }
|
|
77
|
+
const t = Number(ev.time);
|
|
78
|
+
if (!Number.isFinite(t) || t < sinceMs || t > untilMs) continue;
|
|
79
|
+
const u = dshUsageFromEvent(ev);
|
|
80
|
+
if (!u) continue;
|
|
81
|
+
if (!sum) sum = blank();
|
|
82
|
+
sum.input += u.input; sum.output += u.output; sum.cacheRead += u.cacheRead; sum.cacheWrite += u.cacheWrite;
|
|
83
|
+
}
|
|
84
|
+
return sum;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// dsh names a session dir after the cwd it ran in, "/" spelled "-" and a trailing "/" added
|
|
88
|
+
// before the wrap: /a/b/dsh -> --a-b-dsh-- (observed on live ~/.dsh/sessions dirs).
|
|
89
|
+
export const dshSessionDirName = (cwd) => `-${String(cwd + "/").replaceAll("/", "-")}-`;
|
|
90
|
+
|
|
91
|
+
// Whether dsh resumed or started fresh is its own business, so scan every session log for this
|
|
92
|
+
// cwd and keep only in-window events. A log last written before the window opened can hold none.
|
|
93
|
+
export function dshTurnUsage(sessionsRoot, cwd, sinceMs, untilMs, run = spawnSync) {
|
|
94
|
+
if (!sessionsRoot || !cwd || ![sinceMs, untilMs].every(Number.isFinite) || untilMs < sinceMs) return null;
|
|
95
|
+
const dir = join(sessionsRoot, dshSessionDirName(cwd));
|
|
96
|
+
let names;
|
|
97
|
+
try { names = readdirSync(dir); } catch { return null; }
|
|
98
|
+
const sum = blank();
|
|
99
|
+
let found = false;
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
if (!name.startsWith("session-")) continue;
|
|
102
|
+
const f = join(dir, name, "session.jsonl.zstd");
|
|
103
|
+
let mtime;
|
|
104
|
+
try { mtime = statSync(f).mtimeMs; } catch { continue; }
|
|
105
|
+
if (mtime < sinceMs) continue;
|
|
106
|
+
let r;
|
|
107
|
+
try { r = run("zstd", ["-dc", f], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000, maxBuffer: 64 * 1024 * 1024 }); }
|
|
108
|
+
catch { continue; }
|
|
109
|
+
if (!r || r.status !== 0) continue;
|
|
110
|
+
const s = sumDshLog(r.stdout, sinceMs, untilMs);
|
|
111
|
+
if (!s) continue;
|
|
112
|
+
found = true;
|
|
113
|
+
sum.input += s.input; sum.output += s.output; sum.cacheRead += s.cacheRead; sum.cacheWrite += s.cacheWrite;
|
|
114
|
+
}
|
|
115
|
+
return found ? sum : null;
|
|
116
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// #7749: a seat's LIVE turn state, one JSON file per seat — turnstate-<agent>-<project>.json in
|
|
2
|
+
// the bus dir. The runner writes it at every phase transition, its watchdog refreshes liveness
|
|
3
|
+
// mid-turn, so seat-why and the hub presence row read a fact, not an inference from ledger rows.
|
|
4
|
+
// Shape: { turn, phase: idle|working|cut|stalled|parked, since, card, lastBytesAt, lastTranscriptAt }.
|
|
5
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
|
|
9
|
+
const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
10
|
+
export const turnStatePath = (agent, project, dir = busDir()) => join(dir, `turnstate-${agent}-${project}.json`);
|
|
11
|
+
|
|
12
|
+
export function readTurnStateFile(path) {
|
|
13
|
+
try {
|
|
14
|
+
const j = JSON.parse(readFileSync(path, "utf8"));
|
|
15
|
+
return j && j.constructor === Object ? j : null;
|
|
16
|
+
} catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
export function readTurnState(agent, project, dir = busDir()) {
|
|
19
|
+
return readTurnStateFile(turnStatePath(agent, project, dir));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// tmp + rename: a reader mid-write sees the last whole file, never a torn one.
|
|
23
|
+
export function writeTurnStateFile(path, state) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
26
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
27
|
+
writeFileSync(tmp, JSON.stringify(state));
|
|
28
|
+
renameSync(tmp, path);
|
|
29
|
+
return true;
|
|
30
|
+
} catch { return false; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Merge onto the last write: the runner's transition sets phase/since, everything else rides along.
|
|
34
|
+
export function writeTurnState(agent, project, patch, dir = busDir()) {
|
|
35
|
+
const path = turnStatePath(agent, project, dir);
|
|
36
|
+
return writeTurnStateFile(path, { ...(readTurnStateFile(path) || {}), ...patch });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The watchdog's mid-turn refresh. It NEVER writes phase: transitions belong to the runner, and
|
|
40
|
+
// a poll landing after the turn ended must not resurrect "working" over the runner's idle —
|
|
41
|
+
// the turn-number + phase guard is what keeps a slow poll from lying.
|
|
42
|
+
export function refreshTurnLiveness(path, { turn, bytesAt = 0, transcriptAt = 0 }) {
|
|
43
|
+
const cur = readTurnStateFile(path);
|
|
44
|
+
if (!cur || cur.turn !== turn || cur.phase !== "working") return false;
|
|
45
|
+
const patch = {};
|
|
46
|
+
if (bytesAt) patch.lastBytesAt = bytesAt;
|
|
47
|
+
if (transcriptAt) patch.lastTranscriptAt = transcriptAt;
|
|
48
|
+
return Object.keys(patch).length ? writeTurnStateFile(path, { ...cur, ...patch }) : false;
|
|
49
|
+
}
|