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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.mjs +2 -0
- package/bin/crew/state.mjs +22 -5
- package/bin/crew-runner.mjs +34 -1
- package/bin/crew.mjs +2 -2
- package/bin/disk.mjs +90 -0
- package/bin/retire.mjs +5 -1
- package/hooks/await-operator.mjs +77 -0
- package/hooks/hooks.json +18 -0
- package/hooks/lib/await-operator.mjs +49 -0
- package/lib/balances.mjs +10 -4
- package/lib/classify-failure.mjs +5 -2
- package/lib/disk.mjs +421 -0
- package/lib/docker-janitor.mjs +103 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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
|
package/bin/crew/state.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
{
|
package/lib/classify-failure.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|