trantor 0.18.73 → 0.18.75

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.73",
3
+ "version": "0.18.75",
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/cli.mjs CHANGED
@@ -132,6 +132,7 @@ switch (cmd) {
132
132
  case "orchestrate": run("bin/orchestrate.mjs"); break;
133
133
  case "app": run("bin/app.mjs"); break;
134
134
  case "patrol": run("bin/patrol.mjs"); break;
135
+ case "disk": run("bin/disk.mjs"); break;
135
136
  case "retire": run("bin/retire.mjs"); break;
136
137
  case "identity": {
137
138
  const { load, publicView, generate, keyPath } = await import(join(ROOT, "lib/identity.mjs"));
@@ -274,6 +275,7 @@ switch (cmd) {
274
275
  trantor orchestrate a per-project ORCHESTRATOR with a MISSION.md and a pulse: up [--every 10m] | down | status — the loop-orchestrator pattern
275
276
  trantor retire retire orchestrator panes nothing is using — preview by default [--hours N] [--yes] [--json]
276
277
  trantor patrol machine-wide resource sweep: crews/runners/workspaces/orphans — [--json] [--reap] (reap = dead rows + stale artifacts ONLY)
278
+ trantor disk where the disk went + the safe tier to reclaim — [--dry-run] [--clean] [--json] · install (Monday 09:00 launchd)
277
279
 
278
280
  Claude Code plugin (the orchestrator side):
279
281
  claude plugin marketplace add sashabogi/trantor && claude plugin install trantor
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { call, listPids, run } from "./core.mjs";
3
+ import { hostId } from "../../lib/project.mjs";
4
+ import { statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../../lib/docker-janitor.mjs";
3
5
 
4
6
  export function parseRow(line) {
5
7
  const fields = line.split("\t");
@@ -42,8 +44,23 @@ export function dropState(ctx, project, kind, agent = "", handle = "") {
42
44
  writeRows(ctx, rows);
43
45
  }
44
46
 
45
- function killSeatProcesses(ctx, project, agent) {
47
+ // #9778: a seat going down releases the containers its runner recorded — `docker stop` only,
48
+ // never rm, never volumes; docker absent skips silently. Same kill-switch as the process kill.
49
+ async function stopSeatContainers(ctx, project, agent) {
50
+ if (ctx.dry || ctx.env?.CREW_NO_PROC_KILL === "1" || !project || !agent) return;
51
+ const r = dockerSweep({ path: dockerStatePathFor(agent, project) });
52
+ if (r.skipped || !r.stopped.length) return;
53
+ console.log(` 🧹 ${agent}:${project} — stopped ${r.stopped.length} container(s): ${r.stopped.join(", ")}`);
54
+ try {
55
+ const { signedPost } = await import("../../hooks/lib/api.mjs");
56
+ await signedPost("/send", { from: `${hostId()}:${project}`, to: "all", kind: "status", project,
57
+ text: `🧹 ${agent}:${project} torn down — stopped ${r.stopped.length} container(s): ${r.stopped.join(", ")}` }, { project });
58
+ } catch {}
59
+ }
60
+
61
+ async function killSeatProcesses(ctx, project, agent) {
46
62
  if (ctx.env.CREW_NO_PROC_KILL === "1" || !project || !agent) return;
63
+ await stopSeatContainers(ctx, project, agent);
47
64
  const patterns = [`seats/${project}-${agent}\\.sh`, `crew-runner\\.mjs ${agent} .*/${project}$`];
48
65
  for (const pattern of patterns) {
49
66
  for (const pid of listPids(pattern)) run(ctx, "kill", ["-9", pid], { rendered: `kill -9 ${pid} 2>/dev/null` });
@@ -79,7 +96,7 @@ function parseDownArgs(ctx, args) {
79
96
  return parsed;
80
97
  }
81
98
 
82
- function closeRow(ctx, row, selected, orchProjects, adapters, tmuxClosed) {
99
+ async function closeRow(ctx, row, selected, orchProjects, adapters, tmuxClosed) {
83
100
  const perSeat = selected.length > 0;
84
101
  const hasOrch = orchProjects.has(row.project);
85
102
  if (row.kind === "cmuxws" && !perSeat && !hasOrch) adapters.cmux.closeWorkspace(row.handle);
@@ -88,7 +105,7 @@ function closeRow(ctx, row, selected, orchProjects, adapters, tmuxClosed) {
88
105
  if (row.kind === "herdr" && (perSeat || hasOrch)) adapters.herdr.closePane(row.handle);
89
106
  if (row.kind === "tmux") closeTmux(ctx, row, perSeat, tmuxClosed);
90
107
  if ((row.kind === "win" || row.kind === "attach") && !(perSeat && row.kind === "attach")) killTerminal(ctx, row.handle);
91
- if (!["cmuxws", "attach", "herdrws", "orch"].includes(row.kind)) killSeatProcesses(ctx, row.project, row.agent);
108
+ if (!["cmuxws", "attach", "herdrws", "orch"].includes(row.kind)) await killSeatProcesses(ctx, row.project, row.agent);
92
109
  }
93
110
 
94
111
  function closeTmux(ctx, row, perSeat, seen) {
@@ -102,7 +119,7 @@ function closeTmux(ctx, row, perSeat, seen) {
102
119
  run(ctx, "tmux", ["kill-session", "-t", session], { rendered: `tmux kill-session -t '${session}' 2>/dev/null` });
103
120
  }
104
121
 
105
- export function down(ctx, args, adapters) {
122
+ export async function down(ctx, args, adapters) {
106
123
  const options = parseDownArgs(ctx, args);
107
124
  if (options.help) return 0;
108
125
  if (options.error) return 1;
@@ -119,7 +136,7 @@ export function down(ctx, args, adapters) {
119
136
  }
120
137
  const orchProjects = new Set(rows.filter(row => row.kind === "orch").map(row => row.project));
121
138
  const tmuxClosed = new Set();
122
- for (const row of scoped) closeRow(ctx, row, options.agents, orchProjects, adapters, tmuxClosed);
139
+ for (const row of scoped) await closeRow(ctx, row, options.agents, orchProjects, adapters, tmuxClosed);
123
140
  const removed = new Set(scoped.map(row => `${row.project}|${row.kind}|${row.agent}|${row.handle}`));
124
141
  const kept = rows.filter(row => !removed.has(`${row.project}|${row.kind}|${row.agent}|${row.handle}`) || (orchProjects.has(row.project) && ["herdrws", "cmuxws", "orch"].includes(row.kind)));
125
142
  writeRows(ctx, kept);
@@ -26,6 +26,7 @@ import {
26
26
  CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
27
27
  } from "../lib/turn-policy.mjs";
28
28
  import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
29
+ import { listContainers, newContainers, recordContainers, statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../lib/docker-janitor.mjs";
29
30
  import {
30
31
  auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
31
32
  observedDutyNudgeIds, requeueMissingWakeMessages, shedExpiredHubAlerts,
@@ -252,7 +253,7 @@ const EFFORT_FLAG = EFFORT ? cliEffortFlag(AGENT, EFFORT) : { flag: "", text: ""
252
253
 
253
254
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
254
255
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
255
- 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. If your wake names no card id (or cites only a message-card), make relay_board with mine:true your FIRST call — it lists the cards assigned to you (doing/testing/todo, newest first, full card shape); work your newest one (#7763). 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, then STOP: you never close your own card to done — the orchestrator runs the card's drill on the built artifact and closes it, and the hub refuses a move to done on a card with no drill line anyway; 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 testing with slop-gate failing; use 'failed' + a report if anything breaks). If a contract omits a fact you cannot proceed without, ASK — never invent the value: relay_ask(<card>, <question>) blocks the card with your question, keeps the turn owed (no park, no failure), and resumes you when the assigner's answer lands; an invented value that reads as reasoned is the worst outcome this crew ships (#7756). 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. Realigning your seat branch after the orchestrator harvested your commits is \`trantor sync\` run from your worktree: it reads the harvest receipts and refuses when an unharvested commit would be lost, so never reset or rebase onto main by hand. A contract's \`base: <sha>\` line is the integration head: start your seat branch at that sha (\`git reset --hard <sha>\` on the fresh branch), never at origin/main, which trails the orchestrator's unpushed integration commits; the object is already here as the ref \`main\`. If \`git cat-file -e <sha>\` fails, say \`cannot resolve base <sha>\` on the bus and move the card to blocked instead of reasoning from origin/main. Every testing/done note names the sha you verified against as \`verified at <sha>\`; a note without it is flagged HOLLOW. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
256
+ 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. If your wake names no card id (or cites only a message-card), make relay_board with mine:true your FIRST call — it lists the cards assigned to you (doing/testing/todo, newest first, full card shape); work your newest one (#7763). 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, then STOP: you never close your own card to done — the orchestrator runs the card's drill on the built artifact and closes it, and the hub refuses a move to done on a card with no drill line anyway; 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 testing with slop-gate failing; use 'failed' + a report if anything breaks). If your work starts containers or a dev server (\`supabase start\`, \`docker run\`, a watch process), stop them before moving your card to testing and name them in the note — the runner also stops containers it saw your turn create when the card closes, but the first responsibility is yours (#9778). If a contract omits a fact you cannot proceed without, ASK — never invent the value: relay_ask(<card>, <question>) blocks the card with your question, keeps the turn owed (no park, no failure), and resumes you when the assigner's answer lands; an invented value that reads as reasoned is the worst outcome this crew ships (#7756). 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. Realigning your seat branch after the orchestrator harvested your commits is \`trantor sync\` run from your worktree: it reads the harvest receipts and refuses when an unharvested commit would be lost, so never reset or rebase onto main by hand. A contract's \`base: <sha>\` line is the integration head: start your seat branch at that sha (\`git reset --hard <sha>\` on the fresh branch), never at origin/main, which trails the orchestrator's unpushed integration commits; the object is already here as the ref \`main\`. If \`git cat-file -e <sha>\` fails, say \`cannot resolve base <sha>\` on the bus and move the card to blocked instead of reasoning from origin/main. Every testing/done note names the sha you verified against as \`verified at <sha>\`; a note without it is flagged HOLLOW. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
256
257
 
257
258
  // ---- the pulse --------------------------------------------------------------
258
259
  // RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
@@ -304,6 +305,17 @@ const PENDF = join(homedir(), ".agent-bus", `pending-${AGENT}-${PROJ}.json`);
304
305
  // `trantor seat-why` read "live, last turn failed". This file is the state behind that event, so a
305
306
  // reader who missed the message still finds the reason, the evidence and when it lifts.
306
307
  const PARKF = join(homedir(), ".agent-bus", `park-${AGENT}-${PROJ}.json`);
308
+ // #9778: the seat's recorded containers, persisted per seat so a card close, a park or
309
+ // `trantor down <seat>` can stop what the seat started even after this runner is gone.
310
+ const DOCKERF = dockerStatePathFor(AGENT, PROJ);
311
+ // One bus line per sweep, naming what was stopped; docker absent sweeps stay silent by contract.
312
+ async function janitorStop(why, card) {
313
+ const r = dockerSweep({ path: DOCKERF, card });
314
+ if (r.skipped || !r.stopped.length) return;
315
+ const text = `🧹 ${SESSION} stopped ${r.stopped.length} container(s) (${why}): ${r.stopped.join(", ")}${r.failed.length ? ` · could not stop: ${r.failed.join(", ")}` : ""}`;
316
+ log(text);
317
+ await api("/send", { from: SESSION, to: "all", text, project: PROJ, kind: "status" }).catch(() => {});
318
+ }
307
319
  // #7778: message ids this seat already consumed through its OWN inbox path (relay_inbox, the
308
320
  // PostToolUse hook) — reconciled from the hub's deliveredUpTo ledger at each clean turn boundary
309
321
  // and persisted in the pending file, so a message the session read mid-turn is never re-woken by
@@ -466,6 +478,8 @@ async function parkSeat(reason, undelivered, resetHint = 0, { evidence = "" } =
466
478
  // orchestrator stops confusing a quota-held seat with a working or a dead one.
467
479
  writeTurnState(AGENT, PROJ, { phase: "parked", since: Date.now() });
468
480
  await registerStatus(`parked (${reason})`, { phase: "parked", phaseSince: Date.now() });
481
+ // #9778: a parked seat is done working for now — its recorded containers go down with it.
482
+ await janitorStop(`seat parked (${reason})`);
469
483
  return until;
470
484
  }
471
485
 
@@ -725,6 +739,9 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
725
739
  // #7759: the worktree snapshot the turn is judged against at its end — porcelain covers edits
726
740
  // AND new untracked files, which a HEAD-only comparison misses.
727
741
  const statusBefore = gitOut(["status", "--porcelain"], TURN_DIR);
742
+ // #9778: the docker snapshot the turn is judged against — containers the CLI spawns are recorded
743
+ // against the seat's card at turn end, then stopped when the card closes or the seat parks.
744
+ const dockerBefore = listContainers();
728
745
  // #7759: the bus event cursor the turn is judged against — anything the seat posts after this
729
746
  // (relay_send, a card move or note) is bus activity, which counts as work.
730
747
  const busSeqBefore = await latestBusEventId();
@@ -950,6 +967,13 @@ exit $turn_exit`;
950
967
  // turn: an auth death or a null completion is never re-labelled empty (#7759, #5405, #5481).
951
968
  const worktreeChanged = newCommit || gitOut(["status", "--porcelain"], TURN_DIR) !== statusBefore;
952
969
  const busActive = await busActivitySince(busSeqBefore);
970
+ // #9778: containers this turn created are recorded against its card — and land on the telemetry
971
+ // row, which is the ledger the seat record (#7762) is derived from.
972
+ const dockerNew = newContainers(dockerBefore, listContainers());
973
+ if (dockerNew.length) {
974
+ recordContainers({ path: DOCKERF, card: sessionCard || 0, containers: dockerNew });
975
+ log(`turn started ${dockerNew.length} container(s) — recorded against #${sessionCard || 0}: ${dockerNew.map(c => c.name || c.id).join(", ")}`);
976
+ }
953
977
  lastEmptyTurn = !cut && realExit === 0 && effExit === 0 && !authHit && !opts.state
954
978
  && !worktreeChanged && !busActive && !substantiveOutput(ownOut);
955
979
  if (lastEmptyTurn) log("\x1b[33mexit 0 but the turn was EMPTY — no worktree change, no substantive output, no bus activity\x1b[0m");
@@ -994,6 +1018,7 @@ exit $turn_exit`;
994
1018
  }
995
1019
  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 };
996
1020
  if (usage) telemetryRow.usage = usage;
1021
+ if (dockerNew.length) telemetryRow.containers = dockerNew.map(c => c.name || c.id);
997
1022
  if (cut) telemetryRow.cut = true;
998
1023
  if (stallCut) telemetryRow.stalled = true;
999
1024
  if (extensions) { telemetryRow.extensions = extensions; telemetryRow.boxMs = boxMs; }
@@ -1712,6 +1737,14 @@ async function resolveWakeCard(messages, { session }) {
1712
1737
  await reconcileSessionReads(cursor);
1713
1738
  savePending([], []);
1714
1739
  await reportHealthy();
1740
+ // #9778: the seat moves its own card mid-turn — a card that just reached testing/done
1741
+ // releases the containers recorded against it, one bus line naming them.
1742
+ if (sessionCard > 0) {
1743
+ try {
1744
+ const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
1745
+ if (task && (task.status === "testing" || task.status === "done")) await janitorStop(`#${sessionCard} → ${task.status}`, sessionCard);
1746
+ } catch {}
1747
+ }
1715
1748
  await notifyAssigners(assigners,
1716
1749
  `✅ done on ${SESSION} (exit 0, ${secs}s) · asked: "${asked}" · check the board card and the files for what changed`);
1717
1750
  }
package/bin/crew.mjs CHANGED
@@ -102,7 +102,7 @@ async function swap(args) {
102
102
  return 1;
103
103
  }
104
104
  console.log(`— tearing down old seat '${oldAgent}' in ${ctx.project} —`);
105
- down(ctx, [oldAgent], adapters);
105
+ await down(ctx, [oldAgent], adapters);
106
106
  console.log(`— spawning replacement: ${replacement} —`);
107
107
  const code = await runUp([...flags, replacement]);
108
108
  if (!code) console.log(`— swapped. RESEND the contract to '${replacement.split(":")[0]}' (it joined fresh with no context). —`);
@@ -110,7 +110,7 @@ async function swap(args) {
110
110
  }
111
111
 
112
112
  let code = 0;
113
- if (command === "down") code = down(ctx, rawArgs, adapters);
113
+ if (command === "down") code = await down(ctx, rawArgs, adapters);
114
114
  else if (command === "prune") { prune(ctx, adapters); console.log(`— pruned dead crew rows (${ctx.statePath}) —`); }
115
115
  else if (command === "open") code = openOrchestrator(ctx, rawArgs);
116
116
  else if (command === "swap") code = await swap(rawArgs);
package/bin/disk.mjs ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // trantor disk — report where the disk went; --clean runs the safe tier ONLY;
3
+ // install wires a Monday 09:00 launchd job; --weekly is what that job runs
4
+ // (safe clean, then exactly ONE summary message on the bus for the operator).
5
+ import { spawnSync } from "node:child_process";
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { mkdirSync, writeFileSync } from "node:fs";
9
+ import { homedir, platform, userInfo } from "node:os";
10
+ import { runDisk, formatHuman, fmtBytes, launchdPlist } from "../lib/disk.mjs";
11
+
12
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
13
+
14
+ function summaryLine(r, results) {
15
+ const okN = results.filter((x) => x.ok).length;
16
+ const failN = results.length - okN;
17
+ const freed = r.plan.filter((p) => p.type === "build-dir").reduce((n, p) => n + (p.bytes || 0), 0);
18
+ return `disk janitor: safe tier ${okN ? `cleaned ${okN} item(s)` : "nothing to clean"}${failN ? `, ${failN} FAILED` : ""} (~${fmtBytes(freed)} build output), now ${fmtBytes(r.freeBytes)} free of ${fmtBytes(r.totalBytes)} — report: trantor disk`;
19
+ }
20
+
21
+ async function sendWeeklyMessage(text) {
22
+ // One message, signed, into the operator's project lane — never a wall of warnings.
23
+ try {
24
+ const { resolveProject, resolveHubInfo } = await import(join(ROOT, "lib/project.mjs"));
25
+ const { sfetchJson } = await import(join(ROOT, "lib/signed-fetch.mjs"));
26
+ const { loadOrCreate } = await import(join(ROOT, "lib/identity.mjs"));
27
+ const project = resolveProject(process.cwd());
28
+ const url = resolveHubInfo(project).url;
29
+ await sfetchJson(`${url}/send`, {
30
+ payload: { from: "trantor-disk", to: "all", project, kind: "disk", text },
31
+ identity: loadOrCreate("trantor-disk", "agent"),
32
+ });
33
+ } catch (e) {
34
+ process.stderr.write(`disk janitor: bus message failed: ${e.message}\n`);
35
+ }
36
+ }
37
+
38
+ async function install() {
39
+ if (platform() !== "darwin") {
40
+ console.error("trantor disk install: launchd is macOS-only; run `trantor disk --clean` from cron elsewhere");
41
+ process.exit(1);
42
+ }
43
+ const agentDir = join(homedir(), "Library", "LaunchAgents");
44
+ const plistPath = join(agentDir, "com.trantor.disk.plist");
45
+ const logPath = join(homedir(), ".agent-bus", "disk-launchd.log");
46
+ mkdirSync(agentDir, { recursive: true });
47
+ mkdirSync(dirname(logPath), { recursive: true });
48
+ writeFileSync(plistPath, launchdPlist({ nodePath: process.execPath, scriptPath: join(ROOT, "bin", "disk.mjs"), logPath }));
49
+ const uid = userInfo().uid;
50
+ exec("launchctl", ["bootout", `gui/${uid}/com.trantor.disk`]);
51
+ const r = exec("launchctl", ["bootstrap", `gui/${uid}`, plistPath]);
52
+ if (r.status !== 0) {
53
+ // bootout+bootstrap can race an existing job; load -w is the legacy fallback.
54
+ exec("launchctl", ["load", "-w", plistPath]);
55
+ }
56
+ console.log(`installed: ${plistPath}\nruns every Monday 09:00 (safe tier only); result lands as one bus message. Log: ${logPath}`);
57
+ }
58
+
59
+ function exec(file, args) {
60
+ return spawnSync(file, args, { encoding: "utf8" });
61
+ }
62
+
63
+ const args = process.argv.slice(2);
64
+ if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
65
+ console.log(`trantor disk — where the space went, and the provably safe slice to reclaim
66
+
67
+ trantor disk report only (also prints exactly what the safe tier would delete)
68
+ trantor disk --dry-run the same report, explicit
69
+ trantor disk --clean run the SAFE tier: unavailable simulators, build output in dead
70
+ seat worktrees (.next, target, desktop/src-tauri/target),
71
+ opencode sessions idle >14 days. Everything else stays.
72
+ trantor disk --json machine-readable report
73
+ trantor disk install Monday 09:00 launchd job (runs --weekly: clean + one bus message)`);
74
+ process.exit(0);
75
+ }
76
+ if (args[0] === "install") { await install(); process.exit(0); }
77
+
78
+ const weekly = args.includes("--weekly");
79
+ const passArgs = weekly ? [...args, "--clean"] : args;
80
+ const { report, results, json } = await runDisk({ args: passArgs });
81
+ if (json) {
82
+ process.stdout.write(`${JSON.stringify({ report, results }, null, 2)}\n`);
83
+ } else {
84
+ process.stdout.write(formatHuman(report));
85
+ if (results.length) {
86
+ console.log(`\ncleaned: ${results.filter((r) => r.ok).length}, failed: ${results.filter((r) => !r.ok).length}`);
87
+ for (const r of results.filter((r) => !r.ok)) console.log(` FAILED ${r.type} ${r.path || r.id || ""}: ${r.error}`);
88
+ }
89
+ }
90
+ if (weekly) await sendWeeklyMessage(summaryLine(report, results));
package/bin/retire.mjs CHANGED
@@ -37,7 +37,11 @@ async function contractsFor(session) {
37
37
 
38
38
  const panes = collectPanes({ turnInFlight, sessionProcessState, hostId: host }).filter(p => !isRetired(p.sid));
39
39
  for (const p of panes) p.openContracts = await contractsFor(`${host}:${p.project}`);
40
- const decided = panes.map(p => retireDecision(p, { hours }));
40
+ // #8017: the operator names projects to keep open whatever their idle age.
41
+ const keep = new Set(String(flag("--keep") || "").split(",").map(s => s.trim()).filter(Boolean));
42
+ const decided = panes.map(p => keep.has(p.project)
43
+ ? { ...retireDecision(p, { hours }), retire: false, reason: "kept by the operator (--keep)" }
44
+ : retireDecision(p, { hours }));
41
45
  const due = decided.filter(d => d.retire);
42
46
 
43
47
  if (JSON_OUT) {
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // trantor Stop / UserPromptSubmit hook (#9814) — a pane session whose turn ENDS on a question to
3
+ // the operator is waiting, not idle. The app maps a peer status of "blocked …" to needs-you, so at
4
+ // Stop we stamp "blocked · awaiting operator" when the final assistant paragraph ends in "?" or an
5
+ // ask sidecar is still open (#7776); it clears on the next prompt or a convergent no-question Stop.
6
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { sessionContext, signedPost } from "./lib/api.mjs";
10
+ import { endsWithOperatorQuestion, lastAssistantText } from "./lib/await-operator.mjs";
11
+
12
+ const TIMEOUT_MS = Number(process.env.RELAY_AWAIT_OPERATOR_TIMEOUT_MS || 1500);
13
+ const AWAITING_STATUS = "blocked · awaiting operator";
14
+
15
+ const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
16
+ const stampPath = (session) =>
17
+ join(busDir(), `await-operator-${String(session).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
18
+ // Same sanitising as ask-sidecar.mjs's sidecarPath: a session id is a file name, nothing more.
19
+ const sidecarPath = (sid) => {
20
+ const s = String(sid ?? "").trim();
21
+ return s && s !== "." && s !== ".." && /^[A-Za-z0-9._-]+$/.test(s) ? join(busDir(), "asks", `${s}.json`) : null;
22
+ };
23
+
24
+ function readStdin() {
25
+ return new Promise(res => {
26
+ let d = ""; process.stdin.setEncoding("utf8");
27
+ process.stdin.on("data", c => { d += c; });
28
+ process.stdin.on("end", () => res(d));
29
+ setTimeout(() => res(d), 400);
30
+ });
31
+ }
32
+
33
+ // The restore vocabulary is mcp.mjs's boot registration ("active in <project>"), which the app
34
+ // reads as idle — exactly what a session that just got its answer is.
35
+ async function clearStamp(session, project) {
36
+ try { unlinkSync(stampPath(session)); } catch {}
37
+ await signedPost("/register", { session, project, status: `active in ${project}` }, { session, timeoutMs: TIMEOUT_MS });
38
+ }
39
+
40
+ async function main() {
41
+ const raw = await readStdin();
42
+ let input = {};
43
+ try { input = JSON.parse(raw || "{}"); } catch {}
44
+ const event = String(input.hook_event_name ?? "");
45
+ const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
46
+ // Mirror the other hooks: a home-directory session isn't project work and isn't on the bus.
47
+ if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
48
+ if (process.env.TRANTOR_SEAT) return; // the runner owns a crew seat's status vocabulary
49
+ const { session, project } = sessionContext(projectDir);
50
+ const stamped = existsSync(stampPath(session));
51
+
52
+ if (event === "UserPromptSubmit") {
53
+ if (stamped) await clearStamp(session, project);
54
+ return;
55
+ }
56
+ if (event !== "Stop") return;
57
+
58
+ const sidecar = sidecarPath(input.session_id);
59
+ const viaSidecar = sidecar !== null && existsSync(sidecar);
60
+ const asked = viaSidecar || endsWithOperatorQuestion(lastAssistantText(input.transcript_path));
61
+ if (!asked) {
62
+ if (stamped) await clearStamp(session, project); // the turn kept moving; the wait is over
63
+ return;
64
+ }
65
+ const r = await signedPost("/register", { session, project, status: AWAITING_STATUS }, { session, timeoutMs: TIMEOUT_MS });
66
+ if (r.ok) {
67
+ try {
68
+ mkdirSync(busDir(), { recursive: true });
69
+ writeFileSync(stampPath(session), JSON.stringify({ ts: Date.now(), via: viaSidecar ? "ask-sidecar" : "question" }));
70
+ } catch {}
71
+ }
72
+ }
73
+
74
+ main().catch(() => {}).finally(() => {
75
+ try { process.stdout.write("{}"); } catch {}
76
+ process.exit(0);
77
+ });
package/hooks/hooks.json CHANGED
@@ -34,6 +34,15 @@
34
34
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
35
35
  }
36
36
  ]
37
+ },
38
+ {
39
+ "matcher": "",
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/await-operator.mjs"
44
+ }
45
+ ]
37
46
  }
38
47
  ],
39
48
  "PreToolUse": [
@@ -196,6 +205,15 @@
196
205
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/stop-inbox.mjs"
197
206
  }
198
207
  ]
208
+ },
209
+ {
210
+ "matcher": "",
211
+ "hooks": [
212
+ {
213
+ "type": "command",
214
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/await-operator.mjs"
215
+ }
216
+ ]
199
217
  }
200
218
  ]
201
219
  }
@@ -0,0 +1,49 @@
1
+ // Pure helpers for hooks/await-operator.mjs (#9814): read the turn's final assistant text from the
2
+ // transcript tail and decide whether it asks the OPERATOR a question. Kept pure and exported so the
3
+ // hook test drives the detector directly, without a harness or a hub.
4
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
5
+
6
+ // Transcripts grow to MBs; the final assistant message is at the END at Stop time, so a tail window
7
+ // is enough. 128 KiB covers a long closing reply; anything older is not the turn that just ended.
8
+ const TAIL_BYTES = 128 * 1024;
9
+
10
+ /** The verdict zone is the LAST paragraph. A "?" buried mid-reply — a regex like `a?b`, a
11
+ * rhetorical aside, a question the turn itself went on to answer — is not the session waiting on
12
+ * anyone; only a reply that ENDS on a question leaves the operator holding the turn. */
13
+ export function endsWithOperatorQuestion(text) {
14
+ const t = String(text ?? "").replace(/\r\n/g, "\n");
15
+ const paragraphs = t.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
16
+ const last = paragraphs[paragraphs.length - 1] || "";
17
+ return last.endsWith("?");
18
+ }
19
+
20
+ /** The joined text of the LAST assistant message that carries any. A turn that ends on tool calls
21
+ * alone has no prose to judge, so it returns "". Every failure reads as "" — no transcript means
22
+ * no stamp, never a guessed one. */
23
+ export function lastAssistantText(transcriptPath) {
24
+ try {
25
+ if (!transcriptPath || !existsSync(transcriptPath)) return "";
26
+ const size = statSync(transcriptPath).size;
27
+ if (!size) return "";
28
+ const want = Math.min(size, TAIL_BYTES);
29
+ const f = openSync(transcriptPath, "r");
30
+ let tail;
31
+ try {
32
+ const buf = Buffer.alloc(want);
33
+ readSync(f, buf, 0, want, size - want);
34
+ tail = buf.toString("utf8");
35
+ } finally { closeSync(f); }
36
+ const lines = tail.split("\n");
37
+ if (want < size) lines.shift(); // the window can open mid-line; drop the partial
38
+ let text = "";
39
+ for (const line of lines) {
40
+ let entry;
41
+ try { entry = JSON.parse(line); } catch { continue; }
42
+ if (entry?.type !== "assistant") continue;
43
+ const parts = Array.isArray(entry.message?.content) ? entry.message.content : [];
44
+ const joined = parts.filter(p => p?.type === "text").map(p => String(p.text ?? "")).join("\n").trim();
45
+ if (joined) text = joined;
46
+ }
47
+ return text;
48
+ } catch { return ""; }
49
+ }
package/lib/balances.mjs CHANGED
@@ -80,12 +80,18 @@ export const ADAPTERS = [
80
80
  provider: "openrouter", label: "OpenRouter", kind: "prepaid", match: ["openrouter"], envKeys: ["OPENROUTER_API_KEY"],
81
81
  async fetch(key) {
82
82
  const d = (await getJSON("https://openrouter.ai/api/v1/key", key)).data || {};
83
- // limit_remaining is OpenRouter's authoritative credits-left (accounts for top-ups); limit−usage
84
- // is unreliable (usage is lifetime). null limit_remaining + null limit ⇒ unlimited key.
83
+ // #9805: /key limit_remaining is this KEY's spending cap, not the account; the balance is /credits.
85
84
  const unlimited = d.limit_remaining == null && d.limit == null;
86
- const remaining = num(d.limit_remaining) != null ? num(d.limit_remaining)
85
+ const keyRemaining = num(d.limit_remaining) != null ? num(d.limit_remaining)
87
86
  : (num(d.limit) != null ? Math.max(0, num(d.limit) - (num(d.usage) || 0)) : null);
88
- return { remaining, currency: "USD", usage: num(d.usage), limit: num(d.limit), unlimited };
87
+ let account = null;
88
+ try {
89
+ const c = (await getJSON("https://openrouter.ai/api/v1/credits", key)).data || {};
90
+ if (num(c.total_credits) != null) account = num(c.total_credits) - (num(c.total_usage) || 0);
91
+ } catch { /* /credits unreachable: fall back to the key cap, labelled as such */ }
92
+ const remaining = account != null ? account : keyRemaining;
93
+ return { remaining, currency: "USD", usage: num(d.usage), limit: num(d.limit), unlimited,
94
+ source: account != null ? "account" : "key-cap", keyRemaining };
89
95
  },
90
96
  },
91
97
  {
@@ -139,10 +139,13 @@ export function providerRejected(errText) {
139
139
  export const BILLING_RE = /requires more credits|can only afford|http 402|insufficient balance|payment required/i;
140
140
 
141
141
  /** #9724: a billing rejection on a clean exit is the provider refusing the turn. Same doctrine as
142
- * looksLikeAuthDeath: a shipped commit (realWork, checked by the runner via git) is a live turn. */
142
+ * looksLikeAuthDeath: a shipped commit (realWork, checked by the runner via git) is a live turn,
143
+ * and only output short enough to be JUST the error counts (#9812: a turn that ran `git log` over
144
+ * 58fc5e2 quoted the phrases and parked a healthy kimi). */
143
145
  export function looksLikeBillingDeath(ownText, realWork = false) {
144
146
  if (realWork) return false;
145
- return BILLING_RE.test(String(ownText || ""));
147
+ const t = String(ownText || "");
148
+ return t.length < OWN_OUTPUT_ANSWER_MIN && BILLING_RE.test(t);
146
149
  }
147
150
 
148
151
  /** #9724/#8234: a usage record that RESOLVED (the #8234 lookup found the session's record) to all
package/lib/disk.mjs ADDED
@@ -0,0 +1,421 @@
1
+ // trantor disk — measure first, print the list, delete ONLY the provably safe tier
2
+ // (unavailable simulators via simctl, build output in seat worktrees whose runner is
3
+ // dead, opencode sessions idle >14d via the official CLI). Everything else is
4
+ // REPORT-only; our own deletions are fs.rm on paths asserted under $HOME, never rm -rf.
5
+ import { spawnSync } from "node:child_process";
6
+ import * as nodeFs from "node:fs";
7
+ import { join, resolve, sep, basename, dirname } from "node:path";
8
+ import { homedir } from "node:os";
9
+
10
+ export const DAY = 24 * 60 * 60 * 1000;
11
+ export const BUILD_DIRS = [".next", "target", "desktop/src-tauri/target"];
12
+ export const SESSION_IDLE_MAX = 14 * DAY;
13
+
14
+ const GB = 1024 ** 3;
15
+ const KB = 1024;
16
+
17
+ export function fmtBytes(n) {
18
+ if (!Number.isFinite(n)) return "?";
19
+ if (n >= GB) return `${(n / GB).toFixed(1)} GB`;
20
+ if (n >= 1024 ** 2) return `${Math.round(n / 1024 ** 2)} MB`;
21
+ if (n >= KB) return `${Math.round(n / KB)} KB`;
22
+ return `${n} B`;
23
+ }
24
+
25
+ // The one gate every delete-on-disk must pass. resolve() first: a `..` or a symlink
26
+ // spell in the path must not smuggle it outside $HOME past a string prefix check.
27
+ export function assertUnderHome(path, home) {
28
+ const child = resolve(String(path));
29
+ const root = resolve(String(home));
30
+ if (child === root || child.startsWith(root.endsWith(sep) ? root : root + sep)) return child;
31
+ throw new Error(`refused: ${child} is not under ${root}`);
32
+ }
33
+
34
+ function isUnder(path, parent) {
35
+ const child = resolve(String(path));
36
+ const root = resolve(String(parent));
37
+ return child === root || child.startsWith(root.endsWith(sep) ? root : root + sep);
38
+ }
39
+
40
+ // Default dependency kit — every call site takes `d` so tests inject a temp HOME,
41
+ // a canned exec and a recording remove, and no real simctl/opencode/docker ever runs.
42
+ function defaults(d = {}) {
43
+ return {
44
+ fs: d.fs || nodeFs,
45
+ exec: d.exec || ((file, args) => {
46
+ const r = spawnSync(file, args, { encoding: "utf8" });
47
+ return { stdout: r.stdout || "", stderr: r.stderr || "", status: r.status ?? 1 };
48
+ }),
49
+ remove: d.remove || null,
50
+ home: d.home || homedir(),
51
+ now: d.now || Date.now(),
52
+ worktreesRoot: d.worktreesRoot || null,
53
+ entryBudget: d.entryBudget || 400_000,
54
+ };
55
+ }
56
+
57
+ // Recursive size walk. Skips symlinks entirely — a worktree symlink must not make
58
+ // us measure (or later rm) outside its root. Budget caps pathological trees.
59
+ export function dirSize(fs, path, { entryBudget = 400_000 } = {}) {
60
+ let bytes = 0, files = 0, partial = false, seen = 0;
61
+ const stack = [String(path)];
62
+ while (stack.length) {
63
+ if (seen++ > entryBudget) { partial = true; break; }
64
+ const dir = stack.pop();
65
+ let entries;
66
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
67
+ catch { continue; }
68
+ for (const e of entries) {
69
+ const p = join(dir, e.name);
70
+ if (e.isSymbolicLink()) continue;
71
+ if (e.isDirectory()) { stack.push(p); continue; }
72
+ try { bytes += fs.statSync(p).size; files++; } catch {}
73
+ }
74
+ }
75
+ return { bytes, files, partial };
76
+ }
77
+
78
+ export function freeSpace(fs, path = "/") {
79
+ try {
80
+ const s = fs.statfsSync(path);
81
+ return { free: Number(s.bfree) * Number(s.bsize), total: Number(s.blocks) * Number(s.bsize) };
82
+ } catch { return { free: 0, total: 0 }; }
83
+ }
84
+
85
+ // One `ps` snapshot answers "is anything alive that uses this path" for every check
86
+ // below: crew-runner workdirs, opencode run -c, portless/next dev servers all carry
87
+ // the worktree path on their command line. Fails open as [] only when ps is unusable.
88
+ export function liveCommandLines(exec) {
89
+ const r = exec("ps", ["-axo", "command="]);
90
+ if (r.status !== 0 && !r.stdout) return [];
91
+ return r.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
92
+ }
93
+
94
+ function gitOut(exec, args) {
95
+ const r = exec("git", args);
96
+ return r.status === 0 ? String(r.stdout || "") : "";
97
+ }
98
+
99
+ // A build dir that git tracks is source-adjacent state, not disposable output —
100
+ // deleting it would fight the checkout. Checked per candidate, never assumed.
101
+ export function gitTracked(exec, worktree, rel) {
102
+ return gitOut(exec, ["-C", worktree, "ls-files", "--", rel]).trim().length > 0;
103
+ }
104
+
105
+ export function scanSeatWorktrees(d) {
106
+ const { fs, exec, home, now } = d;
107
+ const root = d.worktreesRoot || join(home, ".agent-bus", "worktrees");
108
+ const live = liveCommandLines(exec);
109
+ const seats = [], plan = [], kept = [];
110
+ let projects;
111
+ try { projects = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); }
112
+ catch { return { root, seats, plan, kept }; }
113
+ for (const proj of projects) {
114
+ let seatDirs;
115
+ try { seatDirs = fs.readdirSync(join(root, proj.name), { withFileTypes: true }).filter((e) => e.isDirectory()); }
116
+ catch { continue; }
117
+ for (const seat of seatDirs) {
118
+ const wt = join(root, proj.name, seat.name);
119
+ const isLive = live.some((l) => l.includes(wt));
120
+ const size = dirSize(fs, wt, d);
121
+ seats.push({ project: proj.name, seat: seat.name, path: wt, bytes: size.bytes, live: isLive });
122
+ for (const rel of BUILD_DIRS) {
123
+ const p = join(wt, ...rel.split("/"));
124
+ let st;
125
+ try { st = fs.statSync(p); } catch { continue; }
126
+ if (!st.isDirectory()) continue;
127
+ if (isLive) { kept.push({ path: p, reason: "KEPT: a live seat process uses this worktree" }); continue; }
128
+ if (gitTracked(exec, wt, rel)) { kept.push({ path: p, reason: `KEPT: ${rel} is git-tracked in ${proj.name}/${seat.name}` }); continue; }
129
+ plan.push({ type: "build-dir", path: p, worktree: wt, project: proj.name, seat: seat.name, bytes: dirSize(fs, p, d).bytes });
130
+ }
131
+ }
132
+ }
133
+ seats.sort((a, b) => b.bytes - a.bytes);
134
+ plan.sort((a, b) => b.bytes - a.bytes);
135
+ return { root, seats, plan, kept };
136
+ }
137
+
138
+ export function scanOpencodeSessions(d) {
139
+ const { exec, now } = d;
140
+ const live = liveCommandLines(exec);
141
+ const rows = [], plan = [], kept = [];
142
+ const r = exec("opencode", ["session", "list", "--format", "json"]);
143
+ let list = [];
144
+ if (r.status === 0 && String(r.stdout).includes("[")) {
145
+ try { list = JSON.parse(String(r.stdout).slice(String(r.stdout).indexOf("["))); } catch { list = []; }
146
+ }
147
+ if (!Array.isArray(list)) list = [];
148
+ for (const s of list) {
149
+ const idleMs = now - Number(s.updated || 0);
150
+ const row = { id: s.id, title: s.title, directory: s.directory, idleDays: Math.max(0, Math.round(idleMs / DAY)) };
151
+ rows.push(row);
152
+ if (idleMs <= SESSION_IDLE_MAX) continue;
153
+ if (s.directory && live.some((l) => l.includes(String(s.directory)))) {
154
+ kept.push({ path: s.directory, reason: `KEPT: session ${s.id} sits in a live process directory` });
155
+ continue;
156
+ }
157
+ plan.push({ type: "opencode-session", id: String(s.id), directory: s.directory || "", cmd: ["opencode", "session", "delete", String(s.id)] });
158
+ }
159
+ return { rows, plan, kept, listed: r.status === 0 };
160
+ }
161
+
162
+ export function scanSimulators(d) {
163
+ const { fs, exec, home } = d;
164
+ const r = exec("xcrun", ["simctl", "list", "devices"]);
165
+ let unavailable = 0, available = false;
166
+ if (r.status === 0) {
167
+ available = true;
168
+ let inUnavail = false;
169
+ for (const line of String(r.stdout).split("\n")) {
170
+ if (line.startsWith("--")) { inUnavail = /Unavailable/i.test(line); continue; }
171
+ if (inUnavail && /\(/.test(line)) unavailable++;
172
+ }
173
+ }
174
+ const devicesDir = join(home, "Library", "Developer", "CoreSimulator", "Devices");
175
+ let bytes = 0;
176
+ try { bytes = dirSize(fs, devicesDir, d).bytes; } catch {}
177
+ const plan = available ? [{ type: "simulators", cmd: ["xcrun", "simctl", "delete", "unavailable"] }] : [];
178
+ return { available, unavailable, bytes, plan };
179
+ }
180
+
181
+ export function reportDocker(exec) {
182
+ const df = exec("docker", ["system", "df", "--format", "{{json .}}"]);
183
+ if (df.status !== 0) return { running: false, note: "docker not running — colima stacks unmeasured" };
184
+ const out = {};
185
+ for (const line of String(df.stdout).split("\n").filter(Boolean)) {
186
+ try { const j = JSON.parse(line); if (j.Type) out[j.Type] = { size: j.Size, reusable: j.Reclaimable }; } catch {}
187
+ }
188
+ const names = exec("docker", ["ps", "-a", "--format", "{{.Names}}"]);
189
+ const byProject = {};
190
+ for (const n of String(names.stdout || "").split("\n").filter(Boolean)) {
191
+ // supabase_db_lindadrive → lindadrive; glm8977 → glm8977. Supabase names one
192
+ // container per service, so per-container rows would flood the report.
193
+ const proj = n.startsWith("supabase_") ? (n.split("_").pop() || n) : n.split("-")[0];
194
+ (byProject[proj] ||= []).push(n);
195
+ }
196
+ return { running: true, df: out, byProject };
197
+ }
198
+
199
+ // Size an HF model by its BLOBS, not its symlinks: snapshot files are symlinks to the
200
+ // real weights (per-model blobs/ or the shared hub/blobs store), so realpath each link
201
+ // and count every distinct blob once — by dev:ino, which also dedupes hardlinks and
202
+ // repeated revs. globalSeen keeps a cross-model shared blob out of totalBytes twice.
203
+ export function sizeHfModel(fs, modelDir, { entryBudget = 400_000, globalSeen = null } = {}) {
204
+ let bytes = 0, globalNew = 0, seen = 0, partial = false;
205
+ const counted = new Set();
206
+ const stack = [String(modelDir)];
207
+ while (stack.length) {
208
+ if (seen++ > entryBudget) { partial = true; break; }
209
+ const dir = stack.pop();
210
+ let entries;
211
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
212
+ for (const e of entries) {
213
+ const p = join(dir, e.name);
214
+ if (e.isDirectory()) { stack.push(p); continue; }
215
+ let st;
216
+ try { st = fs.statSync(e.isSymbolicLink() ? fs.realpathSync(p) : p); }
217
+ catch { continue; }
218
+ if (!st.isFile()) continue;
219
+ const key = `${st.dev}:${st.ino}`;
220
+ if (!counted.has(key)) { counted.add(key); bytes += st.size; }
221
+ if (globalSeen && !globalSeen.has(key)) { globalSeen.add(key); globalNew += st.size; }
222
+ }
223
+ }
224
+ return { bytes, globalNew, partial };
225
+ }
226
+
227
+ export function reportHfCache(d) {
228
+ const { fs, home } = d;
229
+ const hub = join(home, ".cache", "huggingface", "hub");
230
+ let entries;
231
+ try { entries = fs.readdirSync(hub, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith("models--")); }
232
+ catch { return { root: hub, models: [], totalBytes: 0 }; }
233
+ const seen = new Set();
234
+ let totalBytes = 0;
235
+ const models = entries.map((e) => {
236
+ const r = sizeHfModel(fs, join(hub, e.name), { entryBudget: d.entryBudget, globalSeen: seen });
237
+ totalBytes += r.globalNew;
238
+ return { model: e.name, bytes: r.bytes };
239
+ }).sort((a, b) => b.bytes - a.bytes).slice(0, 20);
240
+ return { root: hub, models, totalBytes };
241
+ }
242
+
243
+ // Claude agent worktrees are never cleaned here — each row carries dirty and
244
+ // unmerged counts, which is exactly the work a reaper would destroy.
245
+ export function reportClaudeWorktrees(d) {
246
+ const { fs, exec, home } = d;
247
+ const roots = [join(home, ".claude", "worktrees"), join(home, ".claude", "projects")];
248
+ const rows = [];
249
+ for (const root of roots) {
250
+ let tops;
251
+ try { tops = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); } catch { continue; }
252
+ for (const top of tops) {
253
+ const candidates = root.endsWith("projects") ? listNested(fs, join(root, top.name), "worktrees") : [join(root, top.name)];
254
+ for (const wt of candidates) {
255
+ const dirty = gitOut(exec, ["-C", wt, "status", "--porcelain"]).split("\n").filter(Boolean).length;
256
+ const unmerged = Number(gitOut(exec, ["-C", wt, "rev-list", "--count", "main..HEAD"]).trim()) || 0;
257
+ rows.push({ path: wt, bytes: dirSize(fs, wt, d).bytes, dirty, unmerged });
258
+ }
259
+ }
260
+ }
261
+ return { rows: rows.sort((a, b) => b.bytes - a.bytes) };
262
+ }
263
+
264
+ function listNested(fs, dir, name) {
265
+ try {
266
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name === name).map((e) => join(dir, e.name));
267
+ } catch { return []; }
268
+ }
269
+
270
+ // Big-file sweep over the known sink roots — deliberately NOT a whole-$HOME walk.
271
+ export function collectBigFiles(d, minBytes = GB) {
272
+ const { fs, home } = d;
273
+ const roots = [
274
+ join(home, ".agent-bus"), join(home, ".claude"), join(home, ".cache"),
275
+ join(home, ".colima"), join(home, "Library", "Developer"), join(home, ".local", "share"),
276
+ ];
277
+ const found = [];
278
+ let seen = 0;
279
+ const stack = [...roots];
280
+ while (stack.length) {
281
+ if (seen++ > d.entryBudget) break;
282
+ const dir = stack.pop();
283
+ let entries;
284
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
285
+ for (const e of entries) {
286
+ const p = join(dir, e.name);
287
+ if (e.isSymbolicLink()) continue;
288
+ if (e.isDirectory()) { stack.push(p); continue; }
289
+ try {
290
+ const size = fs.statSync(p).size;
291
+ if (size >= minBytes) found.push({ path: p, bytes: size });
292
+ } catch {}
293
+ }
294
+ }
295
+ return found.sort((a, b) => b.bytes - a.bytes).slice(0, 50);
296
+ }
297
+
298
+ export function buildDiskReport(d) {
299
+ const dep = defaults(d);
300
+ const { fs, home, now } = dep;
301
+ const space = freeSpace(fs, "/");
302
+ const seats = scanSeatWorktrees({ ...dep, entryBudget: dep.entryBudget });
303
+ const sessions = scanOpencodeSessions(dep);
304
+ const sims = scanSimulators(dep);
305
+ const dbPath = join(home, ".local", "share", "opencode", "opencode.db");
306
+ const report = {
307
+ generated: new Date(now).toISOString(),
308
+ home,
309
+ freeBytes: space.free,
310
+ totalBytes: space.total,
311
+ seats: { root: seats.root, rows: seats.seats, totalBytes: seats.seats.reduce((n, s) => n + s.bytes, 0) },
312
+ simulators: { available: sims.available, unavailable: sims.unavailable, bytes: sims.bytes },
313
+ opencode: { listed: sessions.listed, rows: sessions.rows, dbPath, dbBytes: fileSize(fs, dbPath) },
314
+ docker: reportDocker(dep.exec),
315
+ hf: reportHfCache(dep),
316
+ claudeWorktrees: reportClaudeWorktrees(dep),
317
+ bigFiles: collectBigFiles(dep),
318
+ plan: [...sims.plan, ...seats.plan, ...sessions.plan],
319
+ kept: [...seats.kept, ...sessions.kept],
320
+ };
321
+ return report;
322
+ }
323
+
324
+ function fileSize(fs, p) {
325
+ try { return fs.statSync(p).size; } catch { return 0; }
326
+ }
327
+
328
+ export function formatHuman(r) {
329
+ const L = [];
330
+ L.push(`trantor disk — ${fmtBytes(r.freeBytes)} free of ${fmtBytes(r.totalBytes)} (${new Date(r.generated).toLocaleString()})`);
331
+ L.push(`seat worktrees: ${fmtBytes(r.seats.totalBytes)} total`);
332
+ for (const s of r.seats.rows.slice(0, 12)) L.push(` ${s.live ? "LIVE" : "dead"} ${s.project}/${s.seat} ${fmtBytes(s.bytes)}`);
333
+ L.push(`simulators: ${fmtBytes(r.simulators.bytes)}${r.simulators.available ? `, ${r.simulators.unavailable} unavailable device(s)` : " (xcrun unavailable)"}`);
334
+ L.push(`opencode sessions: ${r.opencode.rows.length} listed, db ${fmtBytes(r.opencode.dbBytes)} (shrinks only after a VACUUM when no opencode process holds it)`);
335
+ if (r.docker.running) {
336
+ const parts = Object.entries(r.docker.df).map(([k, v]) => `${k} ${v.size}`).join(", ");
337
+ L.push(`docker: ${parts || "no layers"}`);
338
+ for (const [proj, names] of Object.entries(r.docker.byProject)) L.push(` stack ${proj}: ${names.length} container(s)`);
339
+ } else L.push(`docker: ${r.docker.note}`);
340
+ if (r.hf.models.length) {
341
+ L.push(`huggingface cache: ${fmtBytes(r.hf.totalBytes ?? r.hf.models.reduce((n, m) => n + m.bytes, 0))}`);
342
+ for (const m of r.hf.models.slice(0, 8)) L.push(` ${m.model} ${fmtBytes(m.bytes)}`);
343
+ }
344
+ if (r.claudeWorktrees.rows.length) {
345
+ L.push(`claude agent worktrees: ${r.claudeWorktrees.rows.length} (never auto-cleaned)`);
346
+ for (const w of r.claudeWorktrees.rows.slice(0, 8)) L.push(` ${w.dirty} dirty, ${w.unmerged} unmerged ${fmtBytes(w.bytes)} ${basename(dirname(w.path))}/${basename(w.path)}`);
347
+ }
348
+ if (r.bigFiles.length) {
349
+ L.push(`files >1 GB: ${r.bigFiles.length}`);
350
+ for (const f of r.bigFiles.slice(0, 10)) L.push(` ${fmtBytes(f.bytes)} ${f.path}`);
351
+ }
352
+ L.push("");
353
+ L.push(`safe tier would delete ${r.plan.length} item(s):`);
354
+ for (const p of r.plan) L.push(` DELETE ${p.type === "build-dir" ? `${fmtBytes(p.bytes)} ${p.path}` : p.type === "opencode-session" ? `session ${p.id} (idle)` : "unavailable simulators"}`);
355
+ for (const k of r.kept) L.push(` ${k.reason}${k.path ? ` ${k.path}` : ""}`);
356
+ if (!r.kept.length) L.push(" (nothing held back)");
357
+ return `${L.join("\n")}\n`;
358
+ }
359
+
360
+ // Execute the safe tier. simctl/opencode run as their own CLIs (injected in tests);
361
+ // build dirs are removed by the injected `remove` (default fs.rmSync) only after
362
+ // assertUnderHome AND an is-under-worktrees-root re-check on the exact path.
363
+ export async function runClean(r, d) {
364
+ const dep = defaults(d);
365
+ const { exec, home } = dep;
366
+ const remove = dep.remove || ((p, opts) => dep.fs.rmSync(p, opts));
367
+ const results = [];
368
+ for (const item of r.plan) {
369
+ try {
370
+ if (item.type === "simulators") {
371
+ exec("xcrun", ["simctl", "delete", "unavailable"]);
372
+ results.push({ type: item.type, ok: true });
373
+ } else if (item.type === "opencode-session") {
374
+ exec("opencode", ["session", "delete", item.id]);
375
+ results.push({ type: item.type, id: item.id, ok: true });
376
+ } else if (item.type === "build-dir") {
377
+ const p = assertUnderHome(item.path, home);
378
+ if (!isUnder(p, join(home, ".agent-bus", "worktrees"))) throw new Error(`refused: ${p} is not a seat-worktree build dir`);
379
+ remove(p, { recursive: true, force: true });
380
+ results.push({ type: item.type, path: p, ok: true });
381
+ }
382
+ } catch (e) {
383
+ results.push({ type: item.type, path: item.path || item.id, ok: false, error: String(e.message || e) });
384
+ }
385
+ }
386
+ return results;
387
+ }
388
+
389
+ export function launchdPlist({ nodePath, scriptPath, logPath }) {
390
+ return `<?xml version="1.0" encoding="UTF-8"?>
391
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
392
+ <plist version="1.0">
393
+ <dict>
394
+ <key>Label</key><string>com.trantor.disk</string>
395
+ <key>ProgramArguments</key>
396
+ <array>
397
+ <string>${nodePath}</string>
398
+ <string>${scriptPath}</string>
399
+ <string>--weekly</string>
400
+ </array>
401
+ <key>StartCalendarInterval</key>
402
+ <dict>
403
+ <key>Weekday</key><integer>1</integer>
404
+ <key>Hour</key><integer>9</integer>
405
+ <key>Minute</key><integer>0</integer>
406
+ </dict>
407
+ <key>StandardOutPath</key><string>${logPath}</string>
408
+ <key>StandardErrorPath</key><string>${logPath}</string>
409
+ </dict>
410
+ </plist>
411
+ `;
412
+ }
413
+
414
+ export async function runDisk(d = {}) {
415
+ const dep = defaults(d);
416
+ const clean = d.args?.includes("--clean");
417
+ const json = d.args?.includes("--json");
418
+ const report = buildDiskReport(dep);
419
+ const results = clean ? await runClean(report, dep) : [];
420
+ return { report, results, json, clean };
421
+ }
@@ -0,0 +1,103 @@
1
+ // #9778: the stale-resource janitor's docker half. Snapshots, diffs and stops, with the docker
2
+ // exec injectable so a drill never starts a real container. Stop means `docker stop` only —
3
+ // never rm, never volumes: a stopped stack keeps its data and is one `start` away from back.
4
+ import { spawnSync } from "node:child_process";
5
+ import { readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ const dockerExec = (args) =>
10
+ spawnSync("docker", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 8000 });
11
+
12
+ // `docker ps --format '{{.ID}} {{.Names}}'` → [{id, name}]. null = docker absent or the daemon
13
+ // errored (the caller skips silently, by contract); [] = docker up, nothing running.
14
+ export function listContainers(exec = dockerExec) {
15
+ let r;
16
+ try { r = exec(["ps", "--format", "{{.ID}} {{.Names}}"]); } catch { return null; }
17
+ if (!r || r.error || r.status !== 0) return null;
18
+ return String(r.stdout || "").split("\n").map(l => l.trim()).filter(Boolean).map(l => {
19
+ const sp = l.indexOf(" ");
20
+ return sp < 0 ? { id: l, name: l } : { id: l.slice(0, sp), name: l.slice(sp + 1).trim() };
21
+ });
22
+ }
23
+
24
+ // Containers present in `after` that were not in `before`, by id. A null snapshot (docker was
25
+ // absent on either side) diffs to nothing — we never accuse a turn we could not observe.
26
+ export function newContainers(before, after) {
27
+ if (!Array.isArray(before) || !Array.isArray(after)) return [];
28
+ const seen = new Set(before.map(c => c.id));
29
+ return after.filter(c => !seen.has(c.id));
30
+ }
31
+
32
+ // One state file per seat, so a card close, a park or `trantor down <seat>` can still stop what
33
+ // the seat started even after its runner is gone.
34
+ export function statePathFor(agent, project, dir = join(homedir(), ".agent-bus")) {
35
+ return join(dir, `docker-${agent}-${project}.json`);
36
+ }
37
+
38
+ export function loadContainers(path) {
39
+ try {
40
+ const j = JSON.parse(readFileSync(path, "utf8"));
41
+ const cards = j?.cards instanceof Object ? j.cards : {};
42
+ return { cards };
43
+ } catch { return { cards: {} }; }
44
+ }
45
+
46
+ function saveContainers(path, state) {
47
+ try {
48
+ if (!Object.values(state.cards).some(list => Array.isArray(list) && list.length)) {
49
+ try { unlinkSync(path); } catch {}
50
+ return;
51
+ }
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ const tmp = `${path}.${process.pid}.tmp`;
54
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
55
+ renameSync(tmp, path);
56
+ } catch {}
57
+ }
58
+
59
+ // Record containers a turn created against the seat's card (0 = no card bound). Dedupes by id:
60
+ // a container two turns both saw new is recorded once. Returns the rows newly added.
61
+ export function recordContainers({ path, card, containers, now = Date.now() }) {
62
+ if (!containers?.length) return [];
63
+ const state = loadContainers(path);
64
+ const key = String(card || 0);
65
+ const have = new Set((state.cards[key] || []).map(c => c.id));
66
+ const fresh = containers.filter(c => c.id && !have.has(c.id)).map(c => ({ id: c.id, name: c.name || c.id, at: now }));
67
+ if (!fresh.length) return [];
68
+ state.cards[key] = [...(state.cards[key] || []), ...fresh];
69
+ saveContainers(path, state);
70
+ return fresh;
71
+ }
72
+
73
+ // `docker stop <ids>` — the ONLY verb this module ever runs. unavailable = docker is absent or
74
+ // wedged: the caller stays silent and the record stands for the next sweep.
75
+ export function stopContainers({ exec = dockerExec, containers } = {}) {
76
+ const ids = (containers || []).map(c => c.id).filter(Boolean);
77
+ if (!ids.length) return { unavailable: false, stopped: [], failed: [] };
78
+ let r;
79
+ try { r = exec(["stop", ...ids]); } catch { return { unavailable: true, stopped: [], failed: [] }; }
80
+ if (!r || r.error) return { unavailable: true, stopped: [], failed: [] };
81
+ if (r.status !== 0) return { unavailable: false, stopped: [], failed: containers.map(c => c.name || c.id) };
82
+ const stoppedIds = new Set(String(r.stdout || "").split("\n").map(l => l.trim()).filter(Boolean));
83
+ const stopped = [], failed = [];
84
+ for (const c of containers) (stoppedIds.has(c.id) ? stopped : failed).push(c.name || c.id);
85
+ return { unavailable: false, stopped, failed };
86
+ }
87
+
88
+ // Stop everything recorded for one card (or every card when card is undefined) and forget what
89
+ // stopped; what could not be stopped stays recorded so the next sweep tries again.
90
+ export function sweep({ path, exec = dockerExec, card } = {}) {
91
+ const state = loadContainers(path);
92
+ const keys = card === undefined ? Object.keys(state.cards) : [String(card)];
93
+ const targets = keys.flatMap(k => (state.cards[k] || []));
94
+ if (!targets.length) return { skipped: false, stopped: [], failed: [] };
95
+ const r = stopContainers({ exec, containers: targets });
96
+ if (r.unavailable) return { skipped: true, stopped: [], failed: [] };
97
+ for (const k of keys) {
98
+ state.cards[k] = (state.cards[k] || []).filter(c => !r.stopped.includes(c.name || c.id));
99
+ if (!state.cards[k].length) delete state.cards[k];
100
+ }
101
+ saveContainers(path, state);
102
+ return { skipped: false, stopped: r.stopped, failed: r.failed };
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.73",
3
+ "version": "0.18.75",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"