trantor 0.18.117 → 0.18.119
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 +4 -4
- package/bin/crew/processes.mjs +4 -2
- package/bin/crew/state.mjs +23 -1
- package/bin/disk.mjs +16 -12
- package/bin/pair.mjs +14 -21
- package/lib/disk.mjs +165 -32
- package/lib/identity.mjs +66 -0
- package/lib/seat-worktree.mjs +38 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.119",
|
|
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
|
@@ -169,15 +169,15 @@ switch (cmd) {
|
|
|
169
169
|
break;
|
|
170
170
|
}
|
|
171
171
|
case "invite": {
|
|
172
|
-
const {
|
|
172
|
+
const { resolveOwnerIdentity, signRequest } = await import(join(ROOT, "lib/identity.mjs"));
|
|
173
173
|
const nameIdx = args.indexOf("--name"), scopeIdx = args.indexOf("--scope");
|
|
174
174
|
const name = nameIdx >= 0 ? args[nameIdx + 1] : "";
|
|
175
175
|
const scopeRaw = scopeIdx >= 0 ? (args[scopeIdx + 1] || "") : "";
|
|
176
176
|
if (!name || !scopeRaw) { console.error("usage: trantor invite --name <name> --scope <project>:<role>"); process.exit(1); }
|
|
177
177
|
const [project, role = "write"] = scopeRaw.split(":");
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const id =
|
|
178
|
+
const { resolveHubInfo } = await import(join(ROOT, "lib/project.mjs"));
|
|
179
|
+
const hub = resolveHubInfo().url;
|
|
180
|
+
const id = await resolveOwnerIdentity(hub);
|
|
181
181
|
const payload = { scopes: [{ project, role }], ttlSec: 86400 };
|
|
182
182
|
const body = JSON.stringify(payload);
|
|
183
183
|
const sig = signRequest(id, { method: "POST", path: "/invite", body });
|
package/bin/crew/processes.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { call } from "./core.mjs";
|
|
|
4
4
|
|
|
5
5
|
export const runnerRecord = (ctx, project, agent) => join(ctx.seatDir, `${project}-${agent}.runner.json`);
|
|
6
6
|
|
|
7
|
-
export function seatProcesses(ctx, project, agent) {
|
|
7
|
+
export function seatProcesses(ctx, project, agent, exec = call) {
|
|
8
8
|
if (ctx.env.CREW_NO_PROC_KILL === "1") return [];
|
|
9
9
|
let label = agent, dir = ctx.dir;
|
|
10
10
|
try {
|
|
@@ -12,7 +12,9 @@ export function seatProcesses(ctx, project, agent) {
|
|
|
12
12
|
label = record.label;
|
|
13
13
|
dir = record.dir;
|
|
14
14
|
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
15
|
-
const
|
|
15
|
+
const snapshot = exec("ps", ["-axo", "pid=,ppid=,command="]);
|
|
16
|
+
if (snapshot.status !== 0) throw new Error("could not read crew processes");
|
|
17
|
+
const rows = snapshot.stdout.split("\n").map(line => {
|
|
16
18
|
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
17
19
|
return match ? { pid: Number(match[1]), parent: Number(match[2]), command: match[3] } : null;
|
|
18
20
|
}).filter(Boolean);
|
package/bin/crew/state.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { seatProcesses, signalProcesses } from "./processes.mjs";
|
|
4
4
|
import { call, listPids, run } from "./core.mjs";
|
|
5
5
|
import { hostId } from "../../lib/project.mjs";
|
|
6
|
+
import { removeSeatWorktree } from "../../lib/seat-worktree.mjs";
|
|
6
7
|
import { signedPost } from "../../hooks/lib/api.mjs";
|
|
7
8
|
import { statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../../lib/docker-janitor.mjs";
|
|
8
9
|
|
|
@@ -130,7 +131,7 @@ async function unregisterSeat(ctx, project, persona) {
|
|
|
130
131
|
return response.json;
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
export async function down(ctx, args, adapters, { unregister = unregisterSeat } = {}) {
|
|
134
|
+
export async function down(ctx, args, adapters, { unregister = unregisterSeat, removeWorktree = removeSeatWorktree, processes = seatProcesses } = {}) {
|
|
134
135
|
const options = parseDownArgs(ctx, args);
|
|
135
136
|
if (options.help) return 0;
|
|
136
137
|
if (options.error) return 1;
|
|
@@ -175,6 +176,27 @@ export async function down(ctx, args, adapters, { unregister = unregisterSeat }
|
|
|
175
176
|
`${JSON.stringify({ ts: Date.now(), agent: row.persona, project: row.project, stopped: true })}\n`);
|
|
176
177
|
}
|
|
177
178
|
}
|
|
179
|
+
// #12269: a stopped seat's worktree goes with it — `git worktree remove`, branch kept; a tree
|
|
180
|
+
// with uncommitted changes stays on disk and is named here so the operator can recover it.
|
|
181
|
+
for (const row of seats.values()) {
|
|
182
|
+
if (!row.project) continue;
|
|
183
|
+
if (!ctx.dry) {
|
|
184
|
+
try {
|
|
185
|
+
const tracked = readRows(ctx).some(current => current.project === row.project && current.persona === row.persona);
|
|
186
|
+
if (tracked || processes(ctx, row.project, row.persona).length) {
|
|
187
|
+
console.log(` worktree kept (crew row or process remains): ${row.persona}:${row.project}`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.log(` worktree kept (crew liveness unknown: ${error.message}): ${row.persona}:${row.project}`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const r = await removeWorktree({ project: row.project, agent: row.persona, home: ctx.home || undefined, env: ctx.env, dry: ctx.dry });
|
|
196
|
+
if (r.status === "removed") console.log(` worktree removed: ${r.path} (branch seat/${row.persona} kept)`);
|
|
197
|
+
else if (r.status === "kept") console.log(` worktree kept (${r.reason}): ${r.path}`);
|
|
198
|
+
else if (r.status === "would-remove") console.log(` [dry] would remove worktree: ${r.path}`);
|
|
199
|
+
}
|
|
178
200
|
if (failures.length) {
|
|
179
201
|
console.log(`— hub row removal incomplete; retry Stop: ${failures.join("; ")}`);
|
|
180
202
|
return 1;
|
package/bin/disk.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// trantor disk — report where the disk went; --clean runs the safe tier ONLY;
|
|
3
|
-
// install wires a
|
|
4
|
-
// (safe clean, then exactly ONE summary message on the bus for the operator).
|
|
3
|
+
// install wires a daily 09:00 launchd job; --daily (or the legacy --weekly) is what
|
|
4
|
+
// that job runs (safe clean, then exactly ONE summary message on the bus for the operator).
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { join, dirname } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -52,7 +52,7 @@ async function install() {
|
|
|
52
52
|
// bootout+bootstrap can race an existing job; load -w is the legacy fallback.
|
|
53
53
|
exec("launchctl", ["load", "-w", plistPath]);
|
|
54
54
|
}
|
|
55
|
-
console.log(`installed: ${plistPath}\nruns every
|
|
55
|
+
console.log(`installed: ${plistPath}\nruns every day at 09:00 (safe tier only); result lands as one bus message. Log: ${logPath}`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function exec(file, args) {
|
|
@@ -64,19 +64,23 @@ if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
|
|
|
64
64
|
console.log(`trantor disk — where the space went, and the provably safe slice to reclaim
|
|
65
65
|
|
|
66
66
|
trantor disk report only (also prints exactly what the safe tier would delete)
|
|
67
|
-
trantor disk --dry-run the same report, explicit
|
|
68
|
-
trantor disk --clean run the SAFE tier: unavailable simulators,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
trantor disk --dry-run the same report, explicit — never cleans
|
|
68
|
+
trantor disk --clean run the SAFE tier: unavailable simulators, whole seat worktrees
|
|
69
|
+
idle 7+ days with no uncommitted changes (branch kept), build
|
|
70
|
+
output (DerivedData, *.xcresult, target/) idle 3+ days, stale
|
|
71
|
+
scratch (.agent-bus-out/.scratch entries idle 2+ days) and build
|
|
72
|
+
dirs in seat worktrees with no live turn, opencode sessions idle
|
|
73
|
+
>14 days. Everything else stays. Every removal is printed first.
|
|
72
74
|
trantor disk --json machine-readable report
|
|
73
|
-
trantor disk install
|
|
75
|
+
trantor disk install daily 09:00 launchd job (runs --daily: clean + one bus message)`);
|
|
74
76
|
process.exit(0);
|
|
75
77
|
}
|
|
76
78
|
if (args[0] === "install") { await install(); process.exit(0); }
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
+
// #12269: daily cadence (--daily; --weekly kept as the old plist's flag). --dry-run wins.
|
|
81
|
+
const periodic = args.includes("--weekly") || args.includes("--daily");
|
|
82
|
+
const dryRun = args.includes("--dry-run");
|
|
83
|
+
const passArgs = periodic && !dryRun ? [...args, "--clean"] : args;
|
|
80
84
|
const { report, results, json } = await runDisk({ args: passArgs });
|
|
81
85
|
if (json) {
|
|
82
86
|
process.stdout.write(`${JSON.stringify({ report, results }, null, 2)}\n`);
|
|
@@ -87,4 +91,4 @@ if (json) {
|
|
|
87
91
|
for (const r of results.filter((r) => !r.ok)) console.log(` FAILED ${r.type} ${r.path || r.id || ""}: ${r.error}`);
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
|
-
if (
|
|
94
|
+
if (periodic) await sendWeeklyMessage(summaryLine(report, results));
|
package/bin/pair.mjs
CHANGED
|
@@ -21,15 +21,23 @@ if (!role || role.startsWith("--")) {
|
|
|
21
21
|
process.exit(1);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const {
|
|
24
|
+
const { resolveOwnerIdentity, phoneHubAddress, signRequest } = await import(join(ROOT, "lib/identity.mjs"));
|
|
25
25
|
const { resolveHubInfo } = await import(join(ROOT, "lib/project.mjs"));
|
|
26
26
|
|
|
27
27
|
const hub = resolveHubInfo().url.replace(/\/+$/, "");
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
let id, used;
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(`${hub}/health`, { signal: AbortSignal.timeout(8000) });
|
|
31
|
+
if (!response.ok) throw new Error(`hub health returned HTTP ${response.status}`);
|
|
32
|
+
const health = await response.json();
|
|
33
|
+
used = phoneHubAddress(hub, String(health.publicUrl || ""), forceTailnet);
|
|
34
|
+
id = await resolveOwnerIdentity(hub);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = `Pairing failed: ${error.message}. Hub: ${hub}`;
|
|
37
|
+
if (asJson) console.log(JSON.stringify({ error: message }));
|
|
38
|
+
else console.error(message);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
33
41
|
|
|
34
42
|
const payload = { scopes: [{ project: "*", role }], ttlSec: 600, kind: "human" };
|
|
35
43
|
const body = JSON.stringify(payload);
|
|
@@ -64,21 +72,6 @@ if (!r.ok) {
|
|
|
64
72
|
process.exit(1);
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
// v1.4: the link carries the hub's public address (Tailscale Funnel) so the phone pairs without
|
|
68
|
-
// Tailscale. /invite itself always goes to the tailnet hub — owner routes never answer in public.
|
|
69
|
-
// /health reports publicUrl; --tailnet forces the tailnet address. No publicUrl (or /health
|
|
70
|
-
// unreachable — the invite already minted) falls back to the tailnet hub.
|
|
71
|
-
const linkHub = (async () => {
|
|
72
|
-
if (forceTailnet) return { hub, public: false };
|
|
73
|
-
try {
|
|
74
|
-
const h = await fetch(`${hub}/health`).then((r) => r.json()).catch(() => ({}));
|
|
75
|
-
const pub = String(h.publicUrl || "").trim().replace(/\/+$/, "");
|
|
76
|
-
if (pub) return { hub: pub, public: true };
|
|
77
|
-
} catch {}
|
|
78
|
-
return { hub, public: false };
|
|
79
|
-
})();
|
|
80
|
-
|
|
81
|
-
const used = await linkHub;
|
|
82
75
|
const link = `trantor://enroll?hub=${encodeURIComponent(used.hub)}&token=${j.token}`;
|
|
83
76
|
const expiresAt = Number(j.expiresAt) || Date.now() + 600000;
|
|
84
77
|
const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
|
package/lib/disk.mjs
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// REPORT-only; our own deletions are fs.rm on paths asserted under $HOME, never rm -rf.
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import * as nodeFs from "node:fs";
|
|
7
|
-
import { join, resolve, sep, basename, dirname } from "node:path";
|
|
7
|
+
import { join, resolve, sep, basename, dirname, relative } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
+
import { readRows } from "../bin/crew/state.mjs";
|
|
10
|
+
import { seatProcesses } from "../bin/crew/processes.mjs";
|
|
9
11
|
|
|
10
12
|
export const DAY = 24 * 60 * 60 * 1000;
|
|
11
13
|
export const BUILD_DIRS = [".next", "target", "desktop/src-tauri/target"];
|
|
@@ -14,6 +16,12 @@ export const SESSION_IDLE_MAX = 14 * DAY;
|
|
|
14
16
|
// the 10-10 incident was 27 GB of it. Top-level entries untouched this long are safe tier.
|
|
15
17
|
export const SCRATCH_DIRS = [".agent-bus-out", ".scratch"];
|
|
16
18
|
export const SCRATCH_IDLE_MAX = 2 * DAY;
|
|
19
|
+
// #12269: whole seat worktrees idle this long with no uncommitted changes are pruned;
|
|
20
|
+
// build output parked inside the scratch dirs gets its own, shorter window.
|
|
21
|
+
export const WORKTREE_IDLE_MAX = 7 * DAY;
|
|
22
|
+
export const BUILD_OUTPUT_IDLE_MAX = 3 * DAY;
|
|
23
|
+
export const BUILD_OUTPUT_NAMES = ["DerivedData", "target"];
|
|
24
|
+
export const BUILD_OUTPUT_SUFFIX = ".xcresult";
|
|
17
25
|
|
|
18
26
|
const GB = 1024 ** 3;
|
|
19
27
|
const KB = 1024;
|
|
@@ -54,10 +62,17 @@ function defaults(d = {}) {
|
|
|
54
62
|
home: d.home || homedir(),
|
|
55
63
|
now: d.now || Date.now(),
|
|
56
64
|
worktreesRoot: d.worktreesRoot || null,
|
|
65
|
+
log: d.log || console.log,
|
|
57
66
|
entryBudget: d.entryBudget || 400_000,
|
|
58
67
|
};
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
// The seat worktrees root: injected in tests, else the real one under HOME. Every delete
|
|
71
|
+
// below this line re-checks its path is under this root before touching anything.
|
|
72
|
+
export function worktreesRootFor(d) {
|
|
73
|
+
return d.worktreesRoot || join(d.home || homedir(), ".agent-bus", "worktrees");
|
|
74
|
+
}
|
|
75
|
+
|
|
61
76
|
// Recursive size walk. Skips symlinks entirely — a worktree symlink must not make
|
|
62
77
|
// us measure (or later rm) outside its root. Budget caps pathological trees. Dirs are
|
|
63
78
|
// statted too: a rename into an old tree updates the DIR mtime, not the file's, and
|
|
@@ -109,24 +124,26 @@ export function gitTracked(exec, worktree, rel) {
|
|
|
109
124
|
return gitOut(exec, ["-C", worktree, "ls-files", "--", rel]).trim().length > 0;
|
|
110
125
|
}
|
|
111
126
|
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
export function turnStampPath(home, project, seat) {
|
|
116
|
-
return join(home, ".agent-bus", `turnstamp-${seat}-${project}.json`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function seatTurnLive(fs, home, now, project, seat) {
|
|
127
|
+
// #12269: a seat is dead only when both crew ownership and its runner processes are gone.
|
|
128
|
+
function seatRetentionReason(d, project, seat, worktree) {
|
|
129
|
+
const bus = join(d.home, ".agent-bus");
|
|
120
130
|
try {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
const ctx = { statePath: join(bus, "crew-windows.txt"), seatDir: join(bus, "seats"), env: {} };
|
|
132
|
+
if (readRows(ctx).some(row => row.project === project && row.persona === seat)) {
|
|
133
|
+
return `KEPT: ${seat}:${project} has a crew state row`;
|
|
134
|
+
}
|
|
135
|
+
const trees = gitOut(d.exec, ["-C", worktree, "worktree", "list", "--porcelain"]);
|
|
136
|
+
ctx.dir = trees.match(/^worktree (.+)$/m)?.[1] || worktree;
|
|
137
|
+
if (seatProcesses(ctx, project, seat, d.exec).length) return `KEPT: ${seat}:${project} has live crew processes`;
|
|
138
|
+
return "";
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return `KEPT: cannot establish crew liveness for ${seat}:${project}: ${error.message}`;
|
|
141
|
+
}
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
export function scanSeatWorktrees(d) {
|
|
127
145
|
const { fs, exec, home, now } = d;
|
|
128
|
-
const root = d
|
|
129
|
-
const live = liveCommandLines(exec);
|
|
146
|
+
const root = worktreesRootFor(d);
|
|
130
147
|
const seats = [], plan = [], kept = [];
|
|
131
148
|
let projects;
|
|
132
149
|
try { projects = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); }
|
|
@@ -137,10 +154,8 @@ export function scanSeatWorktrees(d) {
|
|
|
137
154
|
catch { continue; }
|
|
138
155
|
for (const seat of seatDirs) {
|
|
139
156
|
const wt = join(root, proj.name, seat.name);
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
? "KEPT: a live seat process uses this worktree"
|
|
143
|
-
: "KEPT: the seat's turn stamp is fresh — a turn may be live";
|
|
157
|
+
const liveReason = seatRetentionReason(d, proj.name, seat.name, wt);
|
|
158
|
+
const isLive = Boolean(liveReason);
|
|
144
159
|
const size = dirSize(fs, wt, d);
|
|
145
160
|
seats.push({ project: proj.name, seat: seat.name, path: wt, bytes: size.bytes, live: isLive });
|
|
146
161
|
for (const rel of BUILD_DIRS) {
|
|
@@ -180,6 +195,101 @@ export function scanSeatWorktrees(d) {
|
|
|
180
195
|
return { root, seats, plan, kept };
|
|
181
196
|
}
|
|
182
197
|
|
|
198
|
+
// #12269: old clean worktrees can go only after crew state and processes both release the seat.
|
|
199
|
+
export function scanStaleWorktrees(d) {
|
|
200
|
+
const { fs, exec, home, now } = d;
|
|
201
|
+
const root = worktreesRootFor(d);
|
|
202
|
+
const plan = [], kept = [];
|
|
203
|
+
let projects;
|
|
204
|
+
try { projects = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); }
|
|
205
|
+
catch { return { root, plan, kept }; }
|
|
206
|
+
for (const proj of projects) {
|
|
207
|
+
let seatDirs;
|
|
208
|
+
try { seatDirs = fs.readdirSync(join(root, proj.name), { withFileTypes: true }).filter((e) => e.isDirectory()); }
|
|
209
|
+
catch { continue; }
|
|
210
|
+
for (const seat of seatDirs) {
|
|
211
|
+
const wt = join(root, proj.name, seat.name);
|
|
212
|
+
const reason = seatRetentionReason(d, proj.name, seat.name, wt);
|
|
213
|
+
if (reason) {
|
|
214
|
+
kept.push({ path: wt, reason });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (gitOut(exec, ["-C", wt, "status", "--porcelain"]).trim()) {
|
|
218
|
+
kept.push({ path: wt, reason: `KEPT: ${proj.name}/${seat.name} has uncommitted changes` });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const size = dirSize(fs, wt, d);
|
|
222
|
+
if (size.maxMtime > now - WORKTREE_IDLE_MAX) {
|
|
223
|
+
kept.push({ path: wt, reason: `KEPT: ${proj.name}/${seat.name} was touched within ${WORKTREE_IDLE_MAX / DAY} days` });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
plan.push({ type: "seat-worktree", path: wt, project: proj.name, seat: seat.name, bytes: size.bytes });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
plan.sort((a, b) => b.bytes - a.bytes);
|
|
230
|
+
return { root, plan, kept };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// #12269: build output parked INSIDE a seat's scratch dirs — DerivedData, target/, *.xcresult —
|
|
234
|
+
// idle past the build-output window is deletable even when it is not a top-level scratch entry
|
|
235
|
+
// (that case the 2-day scratch rule already owns). Top-level entries are never matched here, so
|
|
236
|
+
// the two scans cannot plan the same path. First match wins per subtree; git-tracked paths stay.
|
|
237
|
+
export function scanNestedBuildOutput(d) {
|
|
238
|
+
const { fs, exec, home, now } = d;
|
|
239
|
+
const root = worktreesRootFor(d);
|
|
240
|
+
const plan = [], kept = [];
|
|
241
|
+
const isOutputDir = (name) => BUILD_OUTPUT_NAMES.includes(name) || name.endsWith(BUILD_OUTPUT_SUFFIX);
|
|
242
|
+
let projects;
|
|
243
|
+
try { projects = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); }
|
|
244
|
+
catch { return { plan, kept }; }
|
|
245
|
+
for (const proj of projects) {
|
|
246
|
+
let seatDirs;
|
|
247
|
+
try { seatDirs = fs.readdirSync(join(root, proj.name), { withFileTypes: true }).filter((e) => e.isDirectory()); }
|
|
248
|
+
catch { continue; }
|
|
249
|
+
for (const seat of seatDirs) {
|
|
250
|
+
const wt = join(root, proj.name, seat.name);
|
|
251
|
+
const reason = seatRetentionReason(d, proj.name, seat.name, wt);
|
|
252
|
+
if (reason) { kept.push({ path: wt, reason }); continue; }
|
|
253
|
+
for (const scratch of SCRATCH_DIRS) {
|
|
254
|
+
const dir = join(wt, scratch);
|
|
255
|
+
const stack = [];
|
|
256
|
+
try {
|
|
257
|
+
for (const top of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
258
|
+
if (!top.isDirectory()) continue;
|
|
259
|
+
let entries;
|
|
260
|
+
try { entries = fs.readdirSync(join(dir, top.name), { withFileTypes: true }); } catch { continue; }
|
|
261
|
+
for (const e of entries) if (e.isDirectory()) stack.push(join(dir, top.name, e.name));
|
|
262
|
+
}
|
|
263
|
+
} catch { continue; }
|
|
264
|
+
let seen = 0;
|
|
265
|
+
while (stack.length) {
|
|
266
|
+
if (seen++ > d.entryBudget) break;
|
|
267
|
+
const p = stack.pop();
|
|
268
|
+
if (!isOutputDir(basename(p))) {
|
|
269
|
+
let entries;
|
|
270
|
+
try { entries = fs.readdirSync(p, { withFileTypes: true }); } catch { continue; }
|
|
271
|
+
for (const e of entries) if (e.isDirectory()) stack.push(join(p, e.name));
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
let st;
|
|
275
|
+
try { st = fs.statSync(p); } catch { continue; }
|
|
276
|
+
if (!st.isDirectory()) continue;
|
|
277
|
+
const rel = relative(wt, p);
|
|
278
|
+
if (gitTracked(exec, wt, rel)) { kept.push({ path: p, reason: `KEPT: ${rel} is git-tracked in ${proj.name}/${seat.name}` }); continue; }
|
|
279
|
+
const stat = dirSize(fs, p, d);
|
|
280
|
+
if (stat.maxMtime > now - BUILD_OUTPUT_IDLE_MAX) {
|
|
281
|
+
kept.push({ path: p, reason: `KEPT: ${rel} was touched within ${BUILD_OUTPUT_IDLE_MAX / DAY} days` });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
plan.push({ type: "build-output", path: p, worktree: wt, project: proj.name, seat: seat.name, bytes: stat.bytes });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
plan.sort((a, b) => b.bytes - a.bytes);
|
|
290
|
+
return { plan, kept };
|
|
291
|
+
}
|
|
292
|
+
|
|
183
293
|
export function scanOpencodeSessions(d) {
|
|
184
294
|
const { exec, now } = d;
|
|
185
295
|
const live = liveCommandLines(exec);
|
|
@@ -345,6 +455,8 @@ export function buildDiskReport(d) {
|
|
|
345
455
|
const { fs, home, now } = dep;
|
|
346
456
|
const space = freeSpace(fs, "/");
|
|
347
457
|
const seats = scanSeatWorktrees({ ...dep, entryBudget: dep.entryBudget });
|
|
458
|
+
const stale = scanStaleWorktrees({ ...dep, entryBudget: dep.entryBudget });
|
|
459
|
+
const nested = scanNestedBuildOutput({ ...dep, entryBudget: dep.entryBudget });
|
|
348
460
|
const sessions = scanOpencodeSessions(dep);
|
|
349
461
|
const sims = scanSimulators(dep);
|
|
350
462
|
const dbPath = join(home, ".local", "share", "opencode", "opencode.db");
|
|
@@ -360,16 +472,17 @@ export function buildDiskReport(d) {
|
|
|
360
472
|
hf: reportHfCache(dep),
|
|
361
473
|
claudeWorktrees: reportClaudeWorktrees(dep),
|
|
362
474
|
bigFiles: collectBigFiles(dep),
|
|
363
|
-
plan: [...sims.plan, ...seats.plan, ...sessions.plan],
|
|
364
|
-
kept: [...seats.kept, ...sessions.kept],
|
|
475
|
+
plan: [...sims.plan, ...seats.plan, ...stale.plan, ...nested.plan, ...sessions.plan],
|
|
476
|
+
kept: [...seats.kept, ...stale.kept, ...nested.kept, ...sessions.kept],
|
|
365
477
|
};
|
|
366
478
|
return report;
|
|
367
479
|
}
|
|
368
480
|
|
|
369
|
-
// Bytes the safe tier's ON-DISK items (build dirs, scratch entries
|
|
370
|
-
// the summary line's number. simctl/opencode items free space
|
|
481
|
+
// Bytes the safe tier's ON-DISK items (build dirs, scratch entries, whole worktrees, nested
|
|
482
|
+
// build output) would free — the summary line's number. simctl/opencode items free space
|
|
483
|
+
// inside their own stores.
|
|
371
484
|
export function freedOnDiskBytes(plan) {
|
|
372
|
-
return (plan || []).filter((p) =>
|
|
485
|
+
return (plan || []).filter((p) => ["build-dir", "scratch-entry", "seat-worktree", "build-output"].includes(p.type)).reduce((n, p) => n + (p.bytes || 0), 0);
|
|
373
486
|
}
|
|
374
487
|
|
|
375
488
|
function fileSize(fs, p) {
|
|
@@ -408,25 +521,44 @@ export function formatHuman(r) {
|
|
|
408
521
|
return `${L.join("\n")}\n`;
|
|
409
522
|
}
|
|
410
523
|
|
|
411
|
-
// Execute the safe tier.
|
|
412
|
-
//
|
|
413
|
-
//
|
|
524
|
+
// Execute the safe tier. Every planned item is PRINTED before it is acted on (#12269), so the
|
|
525
|
+
// log is a receipt, not a mystery. simctl/opencode run as their own CLIs (injected in tests);
|
|
526
|
+
// on-disk items are removed by the injected `remove` (default fs.rmSync) or `git worktree
|
|
527
|
+
// remove`, only after assertUnderHome AND an is-under-worktrees-root re-check on the exact path.
|
|
414
528
|
export async function runClean(r, d) {
|
|
415
529
|
const dep = defaults(d);
|
|
416
|
-
const { exec, home } = dep;
|
|
530
|
+
const { exec, home, log } = dep;
|
|
417
531
|
const remove = dep.remove || ((p, opts) => dep.fs.rmSync(p, opts));
|
|
532
|
+
const root = worktreesRootFor(dep);
|
|
418
533
|
const results = [];
|
|
419
534
|
for (const item of r.plan) {
|
|
535
|
+
const label = item.path || (item.type === "opencode-session" ? `session ${item.id}` : item.type === "simulators" ? "unavailable simulators" : item.type);
|
|
536
|
+
log(`janitor: removing ${item.type} ${label}${item.bytes ? ` (~${fmtBytes(item.bytes)})` : ""}`);
|
|
420
537
|
try {
|
|
538
|
+
if (item.path) {
|
|
539
|
+
const p = assertUnderHome(item.path, home);
|
|
540
|
+
if (!isUnder(p, root)) throw new Error(`refused: ${p} is not under the seat worktrees root`);
|
|
541
|
+
const [project, seat] = relative(root, p).split(sep);
|
|
542
|
+
if (!project || !seat) throw new Error("refused: path does not identify a seat worktree");
|
|
543
|
+
const reason = seatRetentionReason(dep, project, seat, join(root, project, seat));
|
|
544
|
+
if (reason) throw new Error(reason);
|
|
545
|
+
}
|
|
421
546
|
if (item.type === "simulators") {
|
|
422
547
|
exec("xcrun", ["simctl", "delete", "unavailable"]);
|
|
423
548
|
results.push({ type: item.type, ok: true });
|
|
424
549
|
} else if (item.type === "opencode-session") {
|
|
425
550
|
exec("opencode", ["session", "delete", item.id]);
|
|
426
551
|
results.push({ type: item.type, id: item.id, ok: true });
|
|
427
|
-
} else if (item.type === "
|
|
552
|
+
} else if (item.type === "seat-worktree") {
|
|
553
|
+
const p = assertUnderHome(item.path, home);
|
|
554
|
+
if (!isUnder(p, root)) throw new Error(`refused: ${p} is not under the seat worktrees root`);
|
|
555
|
+
if (gitOut(exec, ["-C", p, "status", "--porcelain"]).trim()) throw new Error("worktree has uncommitted changes");
|
|
556
|
+
const g = exec("git", ["-C", p, "worktree", "remove", p]);
|
|
557
|
+
if (g.status !== 0) throw new Error(String(g.stderr || "git worktree remove failed").trim().split("\n")[0] || "git worktree remove failed");
|
|
558
|
+
results.push({ type: item.type, path: p, bytes: item.bytes || 0, ok: true });
|
|
559
|
+
} else if (item.type === "build-dir" || item.type === "build-output" || item.type === "scratch-entry") {
|
|
428
560
|
const p = assertUnderHome(item.path, home);
|
|
429
|
-
if (!isUnder(p,
|
|
561
|
+
if (!isUnder(p, root)) throw new Error(`refused: ${p} is not under the seat worktrees root`);
|
|
430
562
|
remove(p, { recursive: true, force: true });
|
|
431
563
|
results.push({ type: item.type, path: p, bytes: item.bytes || 0, ok: true });
|
|
432
564
|
}
|
|
@@ -438,6 +570,7 @@ export async function runClean(r, d) {
|
|
|
438
570
|
}
|
|
439
571
|
|
|
440
572
|
export function launchdPlist({ nodePath, scriptPath, logPath }) {
|
|
573
|
+
// #12269: daily at 09:00 — the weekly cadence lost the race against 100 GB/week of seat junk.
|
|
441
574
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
442
575
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
443
576
|
<plist version="1.0">
|
|
@@ -447,11 +580,10 @@ export function launchdPlist({ nodePath, scriptPath, logPath }) {
|
|
|
447
580
|
<array>
|
|
448
581
|
<string>${nodePath}</string>
|
|
449
582
|
<string>${scriptPath}</string>
|
|
450
|
-
<string>--
|
|
583
|
+
<string>--daily</string>
|
|
451
584
|
</array>
|
|
452
585
|
<key>StartCalendarInterval</key>
|
|
453
586
|
<dict>
|
|
454
|
-
<key>Weekday</key><integer>1</integer>
|
|
455
587
|
<key>Hour</key><integer>9</integer>
|
|
456
588
|
<key>Minute</key><integer>0</integer>
|
|
457
589
|
</dict>
|
|
@@ -464,7 +596,8 @@ export function launchdPlist({ nodePath, scriptPath, logPath }) {
|
|
|
464
596
|
|
|
465
597
|
export async function runDisk(d = {}) {
|
|
466
598
|
const dep = defaults(d);
|
|
467
|
-
|
|
599
|
+
// --dry-run always wins: report only, never clean — whichever flag asked for cleaning.
|
|
600
|
+
const clean = d.args?.includes("--clean") && !d.args?.includes("--dry-run");
|
|
468
601
|
const json = d.args?.includes("--json");
|
|
469
602
|
const report = buildDiskReport(dep);
|
|
470
603
|
const results = clean ? await runClean(report, dep) : [];
|
package/lib/identity.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSy
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
+
import { isIP } from "node:net";
|
|
8
9
|
import { isTestIdentity } from "./test-identity-names.mjs";
|
|
9
10
|
import { asFunction, asString } from "./decode.mjs";
|
|
10
11
|
|
|
@@ -207,6 +208,71 @@ export function publicView(identity) {
|
|
|
207
208
|
return { name: identity.name, kind: identity.kind, pubkey: identity.pubkey, createdAt: identity.createdAt };
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
function loopbackHost(host) {
|
|
212
|
+
return ["localhost", "localhost.", "[::1]"].includes(host) || (isIP(host) === 4 && host.startsWith("127."));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function loopbackHub(base) {
|
|
216
|
+
const url = new URL(base);
|
|
217
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== "/") return false;
|
|
218
|
+
return loopbackHost(url.hostname);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function phoneHubAddress(base, publicUrl = "", forceTailnet = false) {
|
|
222
|
+
const hub = String(!forceTailnet && publicUrl || base).trim().replace(/\/+$/, "");
|
|
223
|
+
const url = new URL(hub);
|
|
224
|
+
if (!/^https?:$/.test(url.protocol) || loopbackHost(url.hostname) || ["0.0.0.0", "[::]"].includes(url.hostname)) {
|
|
225
|
+
throw new Error(`Your phone cannot reach ${hub}: this address only works on this Mac. Use Tailscale on the Mac and iPhone and configure the hub with the Mac's Tailscale address (then use trantor pair --tailnet), or configure a public hub URL.`);
|
|
226
|
+
}
|
|
227
|
+
return { hub, public: !forceTailnet && !!publicUrl };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The hub's owner-only read verifies a candidate without minting an invite or changing scopes.
|
|
231
|
+
export async function resolveOwnerIdentity(base) {
|
|
232
|
+
let config = {};
|
|
233
|
+
try { config = JSON.parse(readFileSync(join(busDir(), "config.json"), "utf8")); } catch {}
|
|
234
|
+
const explicit = process.env.TRANTOR_IDENTITY || process.env.RELAY_OWNER_IDENTITY || config.ownerIdentity;
|
|
235
|
+
const names = explicit ? [explicit] : listKeyNames().flatMap(file => {
|
|
236
|
+
try {
|
|
237
|
+
const record = JSON.parse(readFileSync(join(keysDir(), `${file}.json`), "utf8"));
|
|
238
|
+
return record.kind === "human" && record.name ? [record.name] : [];
|
|
239
|
+
} catch { return []; }
|
|
240
|
+
});
|
|
241
|
+
const request = async (id, path, payload) => {
|
|
242
|
+
const method = payload ? "POST" : "GET";
|
|
243
|
+
const body = payload ? JSON.stringify(payload) : undefined;
|
|
244
|
+
return fetch(`${base.replace(/\/+$/, "")}${path}`, {
|
|
245
|
+
method, body, redirect: "error", headers: { "content-type": "application/json", ...signRequest(id, { method, path, body }) },
|
|
246
|
+
signal: AbortSignal.timeout(8000),
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
let unavailable = "";
|
|
250
|
+
for (const name of names) {
|
|
251
|
+
const id = load(name);
|
|
252
|
+
if (!id) {
|
|
253
|
+
unavailable = `The key for "${name}" is unavailable on this Mac. Unlock the identity Keychain or restore this identity's key.`;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const response = await request(id, "/keys/usage");
|
|
257
|
+
if (response.ok) return id;
|
|
258
|
+
if (response.status === 401 && loopbackHub(base)) {
|
|
259
|
+
const error = await response.json();
|
|
260
|
+
if (error.error === "unknown identity") {
|
|
261
|
+
const enrolled = await request(id, "/enroll", { name: id.name, kind: "human" });
|
|
262
|
+
if (enrolled.ok && (await request(id, "/keys/usage")).ok) return id;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (!names.length && loopbackHub(base)) {
|
|
267
|
+
const id = loadOrCreate("human", "human");
|
|
268
|
+
if (!id) throw new Error("The local human identity exists, but its key is unavailable. Unlock the identity Keychain or restore its key.");
|
|
269
|
+
const enrolled = await request(id, "/enroll", { name: id.name, kind: "human" });
|
|
270
|
+
if (enrolled.ok && (await request(id, "/keys/usage")).ok) return id;
|
|
271
|
+
throw new Error("The local hub refused owner enrollment. Ask its owner for an invite or configure the existing owner identity.");
|
|
272
|
+
}
|
|
273
|
+
throw new Error(unavailable || `No local owner identity is authorized on ${base}. Enroll this Mac with an owner invite, or select its existing owner key with TRANTOR_IDENTITY.`);
|
|
274
|
+
}
|
|
275
|
+
|
|
210
276
|
// --- migrate + status (`trantor keys …`) ---------------------------------------------------------
|
|
211
277
|
// One <safe-name>.json per durable identity; the instances/ subdir holds per-session subkeys that
|
|
212
278
|
// die with the session and stay files by design.
|
package/lib/seat-worktree.mjs
CHANGED
|
@@ -66,10 +66,24 @@ export function ensureSeatWorktree({ sourceDir, project, agent, home = homedir()
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
// #12269: a branch that already exists is the seat's history — down removed the worktree, not
|
|
70
|
+
// the branch. Reset it to HEAD only when it holds nothing HEAD lacks (fully harvested); else
|
|
71
|
+
// re-create at the branch tip so a re-upped seat name reuses the freed worktree with its commits.
|
|
72
|
+
const branchTip = gitOut(["-C", root, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], root);
|
|
73
|
+
const add = ["worktree", "add"];
|
|
74
|
+
let reuseBranch = false;
|
|
75
|
+
if (branchTip) {
|
|
76
|
+
const merged = spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", branch, "HEAD"], { stdio: "ignore", timeout: 8000 }).status === 0;
|
|
77
|
+
if (merged) add.push("--no-track", "-B", branch, seatDir, "HEAD");
|
|
78
|
+
else { add.push(seatDir, branch); reuseBranch = true; }
|
|
79
|
+
} else {
|
|
80
|
+
add.push("--no-track", "-B", branch, seatDir, "HEAD");
|
|
81
|
+
}
|
|
82
|
+
const r = spawnSync("git", ["-C", root, ...add], {
|
|
70
83
|
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000,
|
|
71
84
|
});
|
|
72
85
|
if (r.status === 0) {
|
|
86
|
+
if (reuseBranch) log(`\x1b[2m[runner]\x1b[0m ${branch} re-created at its own tip (${branchTip.slice(0, 7)}) — unintegrated commits kept`);
|
|
73
87
|
// branch.<b>.base names the real base for the Review lens; push.autoSetupRemote makes the
|
|
74
88
|
// first plain push set its upstream (#5403).
|
|
75
89
|
if (base) spawnSync("git", ["-C", seatDir, "config", `branch.${branch}.base`, base], { stdio: "ignore", timeout: 5000 });
|
|
@@ -77,10 +91,33 @@ export function ensureSeatWorktree({ sourceDir, project, agent, home = homedir()
|
|
|
77
91
|
if (!pushAuto) spawnSync("git", ["-C", seatDir, "config", "push.autoSetupRemote", "true"], { stdio: "ignore", timeout: 5000 });
|
|
78
92
|
return { dir: seatDir, created: true, root, dirty: false };
|
|
79
93
|
}
|
|
94
|
+
if (reuseBranch) {
|
|
95
|
+
const reason = String(r.stderr || "").trim().split("\n")[0] || "git worktree add failed";
|
|
96
|
+
log(`\x1b[33m[runner]\x1b[0m ${branch} holds unintegrated commits and could not be re-created (${reason}) — using ${sourceDir} rather than resetting the branch`);
|
|
97
|
+
return { dir: sourceDir, created: false };
|
|
98
|
+
}
|
|
80
99
|
log(`\x1b[33m[runner]\x1b[0m could not create ${branch} worktree — using ${sourceDir}`);
|
|
81
100
|
return { dir: sourceDir, created: false };
|
|
82
101
|
}
|
|
83
102
|
|
|
103
|
+
// #12269: down/retire removes a stopped seat's worktree. The branch always stays; a tree with
|
|
104
|
+
// uncommitted changes is kept and named — git's own refusal is the race-free dirty check.
|
|
105
|
+
export function removeSeatWorktree({ project, agent, home = homedir(), env = process.env, dry = false }) {
|
|
106
|
+
const path = seatWorktreeDir(project, agent, home);
|
|
107
|
+
if (!existsSync(path)) return { status: "absent", path };
|
|
108
|
+
if (env?.CREW_NO_PROC_KILL === "1") return { status: "kept", path, reason: "CREW_NO_PROC_KILL=1 (process kill and removals guarded)" };
|
|
109
|
+
if (dry) return { status: "would-remove", path };
|
|
110
|
+
const r = spawnSync("git", ["-C", path, "worktree", "remove", path], {
|
|
111
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000,
|
|
112
|
+
});
|
|
113
|
+
if (r.status === 0) return { status: "removed", path };
|
|
114
|
+
const dirty = spawnSync("git", ["-C", path, "status", "--porcelain"], {
|
|
115
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000,
|
|
116
|
+
}).stdout || "";
|
|
117
|
+
if (dirty.trim()) return { status: "kept", path, reason: "uncommitted changes" };
|
|
118
|
+
return { status: "kept", path, reason: String(r.stderr || "git worktree remove failed").trim().split("\n")[0] };
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
// The declaration: { link: [<relative sibling path>], provision: [{ path, mode, template? }],
|
|
85
122
|
// preflight: "<shell command>" }. null when the project declares nothing (unchanged behaviour).
|
|
86
123
|
export function readWorktreeDeclaration(root) {
|