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.
- 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 +45 -1
- package/bin/crew.mjs +2 -2
- package/bin/disk.mjs +90 -0
- package/hooks/await-operator.mjs +77 -0
- package/hooks/hooks.json +18 -0
- package/hooks/lib/await-operator.mjs +49 -0
- package/hooks/lib/inbox-page.mjs +58 -0
- package/lib/classify-failure.mjs +43 -0
- package/lib/disk.mjs +421 -0
- package/lib/docker-janitor.mjs +103 -0
- package/mcp.mjs +17 -7
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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
|
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
|
@@ -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
|
+
}
|
package/lib/classify-failure.mjs
CHANGED
|
@@ -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 =
|
|
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).
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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.") },
|