trantor 0.18.74 → 0.18.76

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.74",
3
+ "version": "0.18.76",
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);
@@ -17,6 +17,7 @@ import {
17
17
  AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
18
18
  verdictFor, substantiveOutput, stallVerdict, cutSignalFor,
19
19
  BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict,
20
+ permissionRejection, looksLikePermissionDeath,
20
21
  readPromptText, stripPromptEcho,
21
22
  } from "../lib/classify-failure.mjs";
22
23
  import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
@@ -26,6 +27,7 @@ import {
26
27
  CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
27
28
  } from "../lib/turn-policy.mjs";
28
29
  import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
30
+ import { listContainers, newContainers, recordContainers, statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../lib/docker-janitor.mjs";
29
31
  import {
30
32
  auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
31
33
  observedDutyNudgeIds, requeueMissingWakeMessages, shedExpiredHubAlerts,
@@ -252,7 +254,7 @@ const EFFORT_FLAG = EFFORT ? cliEffortFlag(AGENT, EFFORT) : { flag: "", text: ""
252
254
 
253
255
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
254
256
  // 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.`;
257
+ 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
258
 
257
259
  // ---- the pulse --------------------------------------------------------------
258
260
  // RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
@@ -304,6 +306,17 @@ const PENDF = join(homedir(), ".agent-bus", `pending-${AGENT}-${PROJ}.json`);
304
306
  // `trantor seat-why` read "live, last turn failed". This file is the state behind that event, so a
305
307
  // reader who missed the message still finds the reason, the evidence and when it lifts.
306
308
  const PARKF = join(homedir(), ".agent-bus", `park-${AGENT}-${PROJ}.json`);
309
+ // #9778: the seat's recorded containers, persisted per seat so a card close, a park or
310
+ // `trantor down <seat>` can stop what the seat started even after this runner is gone.
311
+ const DOCKERF = dockerStatePathFor(AGENT, PROJ);
312
+ // One bus line per sweep, naming what was stopped; docker absent sweeps stay silent by contract.
313
+ async function janitorStop(why, card) {
314
+ const r = dockerSweep({ path: DOCKERF, card });
315
+ if (r.skipped || !r.stopped.length) return;
316
+ const text = `🧹 ${SESSION} stopped ${r.stopped.length} container(s) (${why}): ${r.stopped.join(", ")}${r.failed.length ? ` · could not stop: ${r.failed.join(", ")}` : ""}`;
317
+ log(text);
318
+ await api("/send", { from: SESSION, to: "all", text, project: PROJ, kind: "status" }).catch(() => {});
319
+ }
307
320
  // #7778: message ids this seat already consumed through its OWN inbox path (relay_inbox, the
308
321
  // PostToolUse hook) — reconciled from the hub's deliveredUpTo ledger at each clean turn boundary
309
322
  // and persisted in the pending file, so a message the session read mid-turn is never re-woken by
@@ -389,6 +402,8 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
389
402
  : reason === "auth" ? " — check credentials"
390
403
  : reason === "backend-error" ? " — provider backend error (NOT quota): retry, or `trantor swap` to another provider"
391
404
  : reason === "missing-cli" ? " — CLI not on PATH"
405
+ // #9919: the sandbox refused a path outside the worktree — the fix is where the scratch goes.
406
+ : reason === "permission-rejected" ? " — the sandbox refuses paths outside the worktree: keep scratch (logs, baselines, temp files) under <worktree>/.agent-bus-out"
392
407
  // #5481: name the suspected trap, not just the symptom — the dial lives in the provider's
393
408
  // opencode model config (limit.output), not in the runner.
394
409
  : reason === "empty-output" ? (AGENT === "inception"
@@ -466,6 +481,8 @@ async function parkSeat(reason, undelivered, resetHint = 0, { evidence = "" } =
466
481
  // orchestrator stops confusing a quota-held seat with a working or a dead one.
467
482
  writeTurnState(AGENT, PROJ, { phase: "parked", since: Date.now() });
468
483
  await registerStatus(`parked (${reason})`, { phase: "parked", phaseSince: Date.now() });
484
+ // #9778: a parked seat is done working for now — its recorded containers go down with it.
485
+ await janitorStop(`seat parked (${reason})`);
469
486
  return until;
470
487
  }
471
488
 
@@ -725,6 +742,9 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
725
742
  // #7759: the worktree snapshot the turn is judged against at its end — porcelain covers edits
726
743
  // AND new untracked files, which a HEAD-only comparison misses.
727
744
  const statusBefore = gitOut(["status", "--porcelain"], TURN_DIR);
745
+ // #9778: the docker snapshot the turn is judged against — containers the CLI spawns are recorded
746
+ // against the seat's card at turn end, then stopped when the card closes or the seat parks.
747
+ const dockerBefore = listContainers();
728
748
  // #7759: the bus event cursor the turn is judged against — anything the seat posts after this
729
749
  // (relay_send, a card move or note) is bus activity, which counts as work.
730
750
  const busSeqBefore = await latestBusEventId();
@@ -937,6 +957,14 @@ exit $turn_exit`;
937
957
  effExit = 1;
938
958
  log(`\x1b[31mexit 0 but the turn output IS a provider billing rejection — treating as FAILED (exhausted, "${BILLING_RE.exec(ownOut)[0]}")\x1b[0m`);
939
959
  }
960
+ // #9919: the same trap in the sandbox family — the seat reached outside its worktree, opencode
961
+ // auto-rejected the permission and killed the turn at exit 0, and glm:ibkr sat 8h on an
962
+ // uncommitted #9859 under a "success" row. Judged on the output TAIL: the rejection prints where
963
+ // the turn ended, so a turn that only quotes the phrase mid-stream stays success.
964
+ if (realExit === 0 && effExit === 0 && looksLikePermissionDeath(ownOut, newCommit)) {
965
+ effExit = 1;
966
+ log(`\x1b[31mexit 0 but the turn output IS a sandbox permission rejection — treating as FAILED (permission-rejected, "${permissionRejection(ownOut)}")\x1b[0m`);
967
+ }
940
968
  // #5481: exit 0 with an empty ERRF (the TOTAL capture, both streams) is the null-completion trap,
941
969
  // judged on echo-stripped text (#5868). #6969: on a state step ERRF is stderr only, so silence is normal.
942
970
  if (realExit === 0 && effExit === 0 && !lastErrText.trim() && !lastEnvelope.trim()) {
@@ -950,6 +978,13 @@ exit $turn_exit`;
950
978
  // turn: an auth death or a null completion is never re-labelled empty (#7759, #5405, #5481).
951
979
  const worktreeChanged = newCommit || gitOut(["status", "--porcelain"], TURN_DIR) !== statusBefore;
952
980
  const busActive = await busActivitySince(busSeqBefore);
981
+ // #9778: containers this turn created are recorded against its card — and land on the telemetry
982
+ // row, which is the ledger the seat record (#7762) is derived from.
983
+ const dockerNew = newContainers(dockerBefore, listContainers());
984
+ if (dockerNew.length) {
985
+ recordContainers({ path: DOCKERF, card: sessionCard || 0, containers: dockerNew });
986
+ log(`turn started ${dockerNew.length} container(s) — recorded against #${sessionCard || 0}: ${dockerNew.map(c => c.name || c.id).join(", ")}`);
987
+ }
953
988
  lastEmptyTurn = !cut && realExit === 0 && effExit === 0 && !authHit && !opts.state
954
989
  && !worktreeChanged && !busActive && !substantiveOutput(ownOut);
955
990
  if (lastEmptyTurn) log("\x1b[33mexit 0 but the turn was EMPTY — no worktree change, no substantive output, no bus activity\x1b[0m");
@@ -994,6 +1029,7 @@ exit $turn_exit`;
994
1029
  }
995
1030
  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
1031
  if (usage) telemetryRow.usage = usage;
1032
+ if (dockerNew.length) telemetryRow.containers = dockerNew.map(c => c.name || c.id);
997
1033
  if (cut) telemetryRow.cut = true;
998
1034
  if (stallCut) telemetryRow.stalled = true;
999
1035
  if (extensions) { telemetryRow.extensions = extensions; telemetryRow.boxMs = boxMs; }
@@ -1712,6 +1748,14 @@ async function resolveWakeCard(messages, { session }) {
1712
1748
  await reconcileSessionReads(cursor);
1713
1749
  savePending([], []);
1714
1750
  await reportHealthy();
1751
+ // #9778: the seat moves its own card mid-turn — a card that just reached testing/done
1752
+ // releases the containers recorded against it, one bus line naming them.
1753
+ if (sessionCard > 0) {
1754
+ try {
1755
+ const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
1756
+ if (task && (task.status === "testing" || task.status === "done")) await janitorStop(`#${sessionCard} → ${task.status}`, sessionCard);
1757
+ } catch {}
1758
+ }
1715
1759
  await notifyAssigners(assigners,
1716
1760
  `✅ done on ${SESSION} (exit 0, ${secs}s) · asked: "${asked}" · check the board card and the files for what changed`);
1717
1761
  }
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));
@@ -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
+ }
@@ -0,0 +1,58 @@
1
+ // relay_inbox paging (#9822): an overnight backlog once returned 197K chars in ONE tool result.
2
+ // formatInboxPage bounds every read: newest-first, ≤ INBOX_PAGE_SIZE messages, capped near
3
+ // INBOX_PAGE_CHARS, repeated context notices collapsed ×N, footer carrying the before-cursor.
4
+ // Contracts (wake) and questions are never collapsed.
5
+ export const INBOX_PAGE_SIZE = 40;
6
+ export const INBOX_PAGE_CHARS = 20000;
7
+
8
+ // Same line shape mcp.mjs has always emitted, so inbox and wait read identically.
9
+ export const fmtMessage = (m) => `#${m.id} [${m.from} -> ${m.to}] ${new Date(m.ts).toLocaleTimeString()}: ${m.text}`;
10
+
11
+ // Stripping digit runs folds "12:04:11" and "3 cards" into one shape, so the same notice at
12
+ // different times and counts is one repeat. A kind (ask), a wake (contract), or a question mark
13
+ // opts the message out of collapsing entirely.
14
+ const collapsible = (m) => !m.kind && m.wake === false && !String(m.text || "").includes("?");
15
+ const collapseKey = (m) => `${m.from}|${String(m.text || "").replace(/\d+/g, "#")}`;
16
+
17
+ // Newest-first entries {m, n}: n counts how many messages the representative stands for.
18
+ export function collapseMessages(messages) {
19
+ const entries = [];
20
+ const seen = new Map();
21
+ for (let i = (messages || []).length - 1; i >= 0; i--) {
22
+ const m = messages[i];
23
+ if (!collapsible(m)) { entries.push({ m, n: 1 }); continue; }
24
+ const key = collapseKey(m);
25
+ const at = seen.get(key);
26
+ if (at === undefined) { seen.set(key, entries.length); entries.push({ m, n: 1 }); }
27
+ else entries[at].n++;
28
+ }
29
+ return entries;
30
+ }
31
+
32
+ // messages ascending by id (the hub's order) -> bounded newest-first text. The page is the newest
33
+ // `limit` messages that fit the char cap — a CONTIGUOUS slice, so before-cursor paging has no gaps
34
+ // and no double-shown repeats; collapsing is display-only inside the window. Returns
35
+ // { text, shown, hidden, before } — before pages further back via relay_inbox({before}).
36
+ export function formatInboxPage(messages, { limit = INBOX_PAGE_SIZE, capChars = INBOX_PAGE_CHARS } = {}) {
37
+ const list = messages || [];
38
+ const window = [];
39
+ let chars = 0;
40
+ for (let i = list.length - 1; i >= 0 && window.length < limit; i--) {
41
+ const lineLen = fmtMessage(list[i]).length + 8; // slack for a ×N suffix
42
+ // The first line always lands, however long — an empty page would read as "no messages".
43
+ if (window.length && chars + lineLen + 1 > capChars) break;
44
+ window.push(list[i]);
45
+ chars += lineLen + 1;
46
+ }
47
+ const lines = collapseMessages(window.slice().reverse()).map(e => fmtMessage(e.m) + (e.n > 1 ? ` (×${e.n})` : ""));
48
+ const hidden = list.length - window.length;
49
+ let text = lines.join("\n");
50
+ let before = 0;
51
+ const oldestShownId = Number(window[window.length - 1]?.id) || 0;
52
+ if (hidden > 0 && oldestShownId) {
53
+ before = oldestShownId;
54
+ const footer = `… ${hidden} older not shown — relay_inbox({before:${before}}) for more`;
55
+ text = text ? `${text}\n${footer}` : footer;
56
+ }
57
+ return { text, shown: window.length, hidden, before };
58
+ }
@@ -91,6 +91,10 @@ export function verdictFor(realExit, effExit, emptyOutput, ownText, emptyTurn =
91
91
  // real work (checked by the runner via git), and it stands above the captured stream.
92
92
  const bl = looksLikeBillingDeath(ownText);
93
93
  if (bl) return `classified exhausted because ${BILLING_RE.exec(String(ownText || ""))[0]} in the CLI's own output on a clean exit`;
94
+ // #9919: the sandbox family's exit-0 trap — the rejection prints at the TAIL and the runner
95
+ // lifts effExit below; a caller that reaches here un-lifted still names the death.
96
+ const pm = permissionRejection(String(ownText || ""));
97
+ if (pm) return `classified permission-rejected because ${pm} on a clean exit`;
94
98
  return emptyTurn
95
99
  ? "classified empty-turn because exit 0 with no worktree change, no substantive output, no bus activity"
96
100
  : "classified success because exit 0 with CLI output";
@@ -105,6 +109,10 @@ export function verdictFor(realExit, effExit, emptyOutput, ownText, emptyTurn =
105
109
  // credit error must read exhausted here, never fall through to the auth label.
106
110
  const bl = String(ownText || "").match(BILLING_RE);
107
111
  if (bl) return `classified exhausted because ${bl[0]} in the CLI's own output on a clean exit`;
112
+ // #9919: the lifted permission death lands HERE (the runner raised effExit to 1) — it must
113
+ // name permission-rejected plus the refused path, never the auth label below it.
114
+ const pm = permissionRejection(String(ownText || ""));
115
+ if (pm) return `classified permission-rejected because ${pm} on a clean exit`;
108
116
  const m = AUTH_MARKER_RE.exec(String(ownText || ""));
109
117
  return `classified auth because ${m ? m[0] : "auth marker"} in the CLI's own short output`;
110
118
  }
@@ -148,6 +156,37 @@ export function looksLikeBillingDeath(ownText, realWork = false) {
148
156
  return t.length < OWN_OUTPUT_ANSWER_MIN && BILLING_RE.test(t);
149
157
  }
150
158
 
159
+ // ---- the sandbox permission rejection on a clean exit (#9919) -----------------------------------
160
+ // The seat reached outside its worktree and opencode auto-rejected the permission, killed the
161
+ // turn — and exited 0, so the runner read "success" over a turn that died mid-step (#9919). Both
162
+ // deaths print at the TAIL: the turn ends right there, after real work above it.
163
+ export const PERMISSION_RE = /rejected permission to use this specific tool call|permission requested: .*?auto-rejecting/i;
164
+
165
+ /** The judged zone is the TAIL, not the whole capture: a long turn that merely QUOTES the phrase
166
+ * (a contract, a commit message, this file) has it mid-stream, never at the end where a death
167
+ * prints — #9812's whole-text answer floor cannot apply, because a dying turn's capture is long. */
168
+ export const PERMISSION_TAIL = 600;
169
+
170
+ const PERMISSION_PATH_RE = /permission requested:\s*\S+\s*\(([^)]*)\);?\s*auto-rejecting/i;
171
+
172
+ /** The tail's rejection evidence: names the refused path when the auto-reject banner printed one
173
+ * ("/tmp/w0-base/*"), else the matched phrase. Null when the tail carries no rejection. */
174
+ export function permissionRejection(ownText) {
175
+ const tail = String(ownText || "").slice(-PERMISSION_TAIL);
176
+ const m = PERMISSION_RE.exec(tail);
177
+ if (!m) return null;
178
+ const path = (PERMISSION_PATH_RE.exec(tail) || [])[1] || "";
179
+ return path ? `the sandbox auto-rejected permission for ${path}` : m[0];
180
+ }
181
+
182
+ /** #9919: a sandbox permission rejection on a clean exit is the turn dying mid-step. Same doctrine
183
+ * as looksLikeBillingDeath (a shipped commit is a live turn), but judged on the TAIL: the capture
184
+ * above the rejection is real work, not the answer floor's "just the error" shape. */
185
+ export function looksLikePermissionDeath(ownText, realWork = false) {
186
+ if (realWork) return false;
187
+ return permissionRejection(ownText) !== null;
188
+ }
189
+
151
190
  /** #9724/#8234: a usage record that RESOLVED (the #8234 lookup found the session's record) to all
152
191
  * zeros means no model work happened. Null usage — the CLI prints none and keeps none — stays
153
192
  * unknown and is never failure. */
@@ -190,6 +229,10 @@ export function classifyFailure(exit, errText, emptyOutput = false, emptyTurn =
190
229
  // phrase, and either pattern reading first would relabel the same provider-side rejection.
191
230
  const pr = providerRejected(String(errText || ""));
192
231
  if (pr) return pr;
232
+ // #9919: a permission death with a non-zero exit is still the sandbox refusing the turn — named
233
+ // on the TAIL evidence so a crash that merely quoted the phrase mid-stream stays crashed.
234
+ const perm = permissionRejection(String(errText || ""));
235
+ if (perm) return { reason: "permission-rejected", matched: perm };
193
236
  // "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
194
237
  // your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
195
238
  // #9724: the OpenRouter billing phrasing — "requires more credits … can only afford 16" — and
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/mcp.mjs CHANGED
@@ -15,6 +15,7 @@ import { loadSeatRecord } from "./lib/seat-record.mjs";
15
15
  import { resolveProject, hostId, resolveHubInfo, nonSeatReason, handoffDir, orchWriterSid } from "./lib/project.mjs";
16
16
  import { signedPost, signedGet } from "./hooks/lib/api.mjs";
17
17
  import { anchorCursor } from "./hooks/lib/inbox-ledger.mjs";
18
+ import { formatInboxPage, fmtMessage } from "./hooks/lib/inbox-page.mjs";
18
19
  import { assertNoSecrets } from "./lib/scrub.mjs";
19
20
  import { isMessageCardTitle, OPEN_CARD_STATUSES } from "./lib/turn-policy.mjs";
20
21
  import { markDoing, hollowVerdict, blastRadius, blastLine } from "./hooks/lib/hollow-move.mjs";
@@ -121,7 +122,7 @@ async function api(method, path, payload, { timeoutMs } = {}) {
121
122
  }
122
123
  return r.json;
123
124
  }
124
- const fmt = (m) => `#${m.id} [${m.from} -> ${m.to}] ${new Date(m.ts).toLocaleTimeString()}: ${m.text}`;
125
+ const fmt = fmtMessage;
125
126
 
126
127
  const server = new McpServer({ name: "trantor", version: "0.1.0" });
127
128
 
@@ -543,12 +544,21 @@ server.tool("relay_handoff", "Write a rich handoff for THIS session so a fresh s
543
544
  return { content: [{ type: "text", text: `handoff saved (${rec.id}). A fresh session in ${name} will load it on start. Tell the user to open a new terminal here.` }] };
544
545
  });
545
546
 
546
- server.tool("relay_inbox", "Read NEW messages addressed to this session since the last read (non-blocking).", {}, async () => {
547
- await seedCursor();
548
- const { messages, cursor: c } = await api("GET", `/inbox?session=${encodeURIComponent(SESSION)}&since=${cursor}`);
549
- cursor = c;
550
- return { content: [{ type: "text", text: messages.length ? messages.map(fmt).join("\n") : "(no new messages)" }] };
551
- });
547
+ server.tool("relay_inbox", "Read NEW messages addressed to this session since the last read (non-blocking). The result is a bounded page — newest first, repeats of the same notice collapsed with ×N, capped near 20K chars. When older messages exist it ends with a before-cursor: pass it back as relay_inbox({before}) to page into older history.",
548
+ { before: z.number().optional().describe("page BACK: return only messages with id < before (the cursor from the 'older not shown' footer). A peek — does not advance the new-message cursor.") },
549
+ async ({ before }) => {
550
+ if (before) {
551
+ // History paging peeks from 0 and filters: it must not advance the delivery ledger.
552
+ const { messages } = await api("GET", `/inbox?session=${encodeURIComponent(SESSION)}&since=0&peek=1`);
553
+ const page = formatInboxPage((messages || []).filter(m => Number(m.id) < before));
554
+ return { content: [{ type: "text", text: page.text || `(nothing older than #${before})` }] };
555
+ }
556
+ await seedCursor();
557
+ const { messages, cursor: c } = await api("GET", `/inbox?session=${encodeURIComponent(SESSION)}&since=${cursor}`);
558
+ cursor = c;
559
+ const page = formatInboxPage(messages || []);
560
+ return { content: [{ type: "text", text: page.text || "(no new messages)" }] };
561
+ });
552
562
 
553
563
  server.tool("relay_wait", "Block up to `timeout` seconds waiting for the next message to this session (long-poll). Returns the instant a message arrives. When idle, park by calling this repeatedly. IMPORTANT: MCP clients cap or background long tool calls — OpenCode ~60s; Codex ~120s; Claude Code auto-backgrounds ANY MCP call at 120s (CC 2.1.212+, tunable via CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS), which breaks inline parking. Use timeout 50 and loop for cross-client safety.",
554
564
  { timeout: z.number().optional().describe("seconds to wait, default 25. Effective wait is capped at 110s so the call always returns inline (a longer wait would be auto-backgrounded by Claude Code's 120s MCP floor or dropped by other clients). Use 50 and call repeatedly to park while idle.") },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.74",
3
+ "version": "0.18.76",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"