trantor 0.18.116 → 0.18.118

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.116",
3
+ "version": "0.18.118",
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": {
@@ -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 rows = call("ps", ["-axo", "pid=,ppid=,command="]).stdout.split("\n").map(line => {
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);
@@ -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 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).
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 Monday 09:00 (safe tier only); result lands as one bus message. Log: ${logPath}`);
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, build output and
69
- stale scratch (.agent-bus-out/.scratch entries idle 2+ days) in
70
- seat worktrees with no live turn (ps + the runner's turn stamp),
71
- opencode sessions idle >14 days. Everything else stays.
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 Monday 09:00 launchd job (runs --weekly: clean + one bus message)`);
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
- const weekly = args.includes("--weekly");
79
- const passArgs = weekly ? [...args, "--clean"] : args;
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 (weekly) await sendWeeklyMessage(summaryLine(report, results));
94
+ if (periodic) await sendWeeklyMessage(summaryLine(report, results));
package/hub/auth.mjs CHANGED
@@ -1,19 +1,60 @@
1
1
  /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: Auth normalizes untrusted request and persisted identity shapes at their established hub boundary; this split preserves those guards unchanged. */
2
- import { verifyRequest, verifyEndorsement } from "../lib/identity.mjs";
2
+ import { verifyRequest, verifyEndorsement, HDR } from "../lib/identity.mjs";
3
3
  import { readFileSync } from "node:fs";
4
+ import { StringDecoder } from "node:string_decoder";
4
5
  import { timingSafeEqual, randomBytes } from "node:crypto";
5
6
  import { publicView } from "../lib/identity.mjs";
6
7
  import { IDENTITY_KINDS } from "../lib/store-contract.mjs";
7
8
 
8
9
  export function createAuthRuntime({ state, markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS, LOOPBACK_BIND, ENROLL_MODE, seenNonces = new Map() }) {
9
10
  const now = () => Date.now();
10
- function rawBody(req) {
11
+ // Route measurements and supported envelopes: docs/REQUEST-BODY-LIMITS.md.
12
+ const BODY_CAP_DEFAULT = 4 * 1024 * 1024;
13
+ const BODY_CAP_PROMPT = 16 * 1024 * 1024;
14
+ // More than 100x the measured 115,194-byte desktop snapshot.
15
+ const BODY_CAP_OWNER_CHAT = 16 * 1024 * 1024;
16
+ // About 6x the measured 10,659,828-byte real state.
17
+ const BODY_CAP_OWNER_IMPORT = 64 * 1024 * 1024;
18
+ const OWNER_BODY_CAPS = new Map([
19
+ ["/session/chat", BODY_CAP_OWNER_CHAT],
20
+ ["/import", BODY_CAP_OWNER_IMPORT],
21
+ ["/handoff", BODY_CAP_DEFAULT], ["/task", BODY_CAP_DEFAULT], ["/task/update", BODY_CAP_DEFAULT],
22
+ ]);
23
+ function bodyCap(P) { return P === "/session/prompt" ? BODY_CAP_PROMPT : BODY_CAP_DEFAULT; }
24
+ function ownerBodyCap(req, P) {
25
+ const cap = OWNER_BODY_CAPS.get(P) || bodyCap(P);
26
+ if (cap <= bodyCap(P) || !Object.values(HDR).every(h => req.headers[h])) return bodyCap(P);
27
+ // Header identity is only a read-budget hint. Larger bodies require strict owner auth before parsing.
28
+ const identity = findIdentity(req.headers[HDR.pubkey]) || findIdentity(req.headers["x-trantor-durable"]);
29
+ return identity?.scopes?.some(s => s.role === "owner") ? cap : bodyCap(P);
30
+ }
31
+ function rawBody(req, maxBytes = BODY_CAP_DEFAULT) {
32
+ if (req._bodyTooLarge) return Promise.resolve(undefined);
11
33
  if (req._rawBody !== undefined) return Promise.resolve(req._rawBody);
12
- return new Promise(r => { let d = ""; req.on("data", c => (d += c)); req.on("end", () => { req._rawBody = d; r(d); }); });
34
+ return new Promise(r => {
35
+ let d = "", bytes = 0, done = false;
36
+ const decoder = new StringDecoder("utf8");
37
+ const finish = (v) => { if (!done) { done = true; r(v); } };
38
+ req.on("data", c => {
39
+ if (done) return;
40
+ bytes += c.length;
41
+ if (bytes > maxBytes) {
42
+ req._bodyTooLarge = true;
43
+ finish(undefined);
44
+ req.resume(); // keep draining (and discarding) so the socket can close cleanly
45
+ return;
46
+ }
47
+ d += decoder.write(c);
48
+ });
49
+ req.on("end", () => { if (!req._bodyTooLarge) { req._bodyBytes = bytes; req._rawBody = d + decoder.end(); finish(req._rawBody); } });
50
+ req.on("error", () => finish(undefined));
51
+ });
13
52
  }
14
- async function body(req) {
53
+ async function body(req, maxBytes = BODY_CAP_DEFAULT) {
54
+ if (req._bodyTooLarge) return undefined;
15
55
  if (req._jsonBody !== undefined) return req._jsonBody;
16
- const d = await rawBody(req);
56
+ const d = await rawBody(req, maxBytes);
57
+ if (d === undefined) return undefined;
17
58
  try { req._jsonBody = d ? JSON.parse(d) : {}; } catch { req._jsonBody = {}; }
18
59
  return req._jsonBody;
19
60
  }
@@ -53,7 +94,7 @@ function cmpSemver(a, b) {
53
94
  }
54
95
  const AUTH_HEADERS = ["x-trantor-pubkey", "x-trantor-sig", "x-trantor-ts", "x-trantor-nonce"];
55
96
  const PUBLIC_ENDPOINTS = new Set(["/", "/ui", "/health", "/enroll"]);
56
- const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/session/prompts", "/session/prompt/claim", "/session/prompt/ack"]);
97
+ const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/session/prompts", "/session/prompt/claim", "/session/prompt/ack", "/session/attachment"]);
57
98
  const DEVICE_ENDPOINTS = new Set(["/device/register", "/device/unregister", "/devices"]);
58
99
  const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims", "/proposals", "/grants", "/overseer/context", "/overseer/status", "/session/chat", "/session/prompt", "/crew/roster"]);
59
100
  const roleRank = { read: 1, write: 2, approver: 3, owner: 4 };
@@ -99,7 +140,7 @@ function isDutyIdentity(auth) {
99
140
  const canRead = (auth, project) => AUTH_MODE !== "enforce" && !auth?.identity
100
141
  ? true
101
142
  : isDutyIdentity(auth) || scopeAllows(auth?.identity, project, "read");
102
- function projectFromRequest(P, q, b) {
143
+ function projectFromRequest(P, q, b, method) {
103
144
  if (P === "/task/update" || P === "/card") {
104
145
  const t = state.tasks.find(x => x.id === Number(b?.id ?? q?.id));
105
146
  return t?.project || "";
@@ -117,8 +158,11 @@ function projectFromRequest(P, q, b) {
117
158
  return p?.project || "";
118
159
  }
119
160
  // claim/ack and the single-prompt GET carry only an id — authorization runs against the
120
- // PROMPT's project, or a project-scoped key could claim any project's queued prompt (#12129).
121
- if (P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack") {
161
+ // PROMPT's project (#12129); the attachment download is keyed the same way. POST
162
+ // /session/prompt is the exception: it CREATES a row in b.project, so an id in the body must
163
+ // not borrow an existing row's project to write elsewhere (#12161 review).
164
+ if (P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack" || P === "/session/attachment") {
165
+ if (P === "/session/prompt" && method === "POST") return canon(String(b?.project || q?.project || "").slice(0, 80));
122
166
  const pr = (state.sessionPrompts || []).find(x => x.id === Number(b?.id ?? q?.id));
123
167
  if (pr) return pr.project || "";
124
168
  if (P !== "/session/prompt") return "";
@@ -198,11 +242,11 @@ function supersessionActive(rec) {
198
242
  }
199
243
  return insts.some(i => i !== rec && i.name === rec.name && !i.superseded && (i.lastSeen || 0) > cut);
200
244
  }
201
- async function authenticate(req, path) {
202
- if (AUTH_MODE === "off") return { ok: true, mode: AUTH_MODE, trusted: true };
245
+ async function authenticate(req, path, strict = false) {
246
+ if (AUTH_MODE === "off" && !strict) return { ok: true, mode: AUTH_MODE, trusted: true };
203
247
  const signed = hasAuthHeaders(req);
204
248
  if (!signed) {
205
- if (AUTH_MODE === "warn") {
249
+ if (AUTH_MODE === "warn" && !strict) {
206
250
  process.stderr.write(`[trantor] auth warn: unsigned ${req.method} ${path}\n`);
207
251
  return { ok: true, mode: AUTH_MODE, trusted: false, warning: "unsigned" };
208
252
  }
@@ -210,7 +254,7 @@ async function authenticate(req, path) {
210
254
  }
211
255
  const raw = req.method === "GET" || req.method === "HEAD" ? undefined : await rawBody(req);
212
256
  // Auth warn mode annotates signature, replay and enrollment failures; enforce mode rejects them.
213
- const soft = (warning) => AUTH_MODE === "warn"
257
+ const soft = (warning) => AUTH_MODE === "warn" && !strict
214
258
  ? { ok: true, mode: AUTH_MODE, trusted: false, warning }
215
259
  : { ok: false, code: 401, error: warning };
216
260
  const verified = verifyRequest({ headers: req.headers, method: req.method, path, body: raw });
@@ -351,7 +395,7 @@ function canUseInboxSession(auth, session) {
351
395
  }
352
396
 
353
397
  return {
354
- PUBLIC_ENDPOINTS, authPath, authenticate, authorize, body, rawBody, json,
398
+ PUBLIC_ENDPOINTS, authPath, authenticate, authorize, body, bodyCap, ownerBodyCap, rawBody, json,
355
399
  canon, cleanScope, defaultScopesFor, findIdentity, scopeAllows, canRead, isDutyIdentity,
356
400
  projectFromRequest, crossProjectGuard, filterReadable, filterDiscoverable,
357
401
  inboxReadable, canUseInboxSession, overseerPolicy, subFp, PROPOSAL_CAP,
package/hub/public.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isIP } from "node:net";
2
+ import { HDR } from "../lib/identity.mjs";
2
3
 
3
4
  const ALLOWED = new Set([
4
5
  ...["/health", "/projects", "/catchup", "/tasks", "/card", "/events", "/stream",
@@ -31,7 +32,7 @@ export function publicClientIP(req) {
31
32
  return normalizedIP(forwarded) || socket;
32
33
  }
33
34
 
34
- export function createPublicGate({ now = Date.now, log = line => process.stderr.write(`${line}\n`) } = {}) {
35
+ export function createPublicGate({ now = Date.now, log = line => process.stderr.write(`${line}\n`), canUpload = () => false } = {}) {
35
36
  const clients = new Map();
36
37
  const admissions = new WeakMap();
37
38
  const refusals = new Map();
@@ -92,6 +93,18 @@ export function createPublicGate({ now = Date.now, log = line => process.stderr.
92
93
  count("auth");
93
94
  } else if (res.statusCode === 403) count(path === "/enroll" ? "enrollment" : "authorization");
94
95
  });
96
+ // Only health is unsigned; reject absent signature fields before the handler reads a body.
97
+ if (path !== "/health" && Object.values(HDR).some(header => !req.headers[header])) {
98
+ res.writeHead(401, { "content-type": "application/json", "connection": "close" });
99
+ res.end(JSON.stringify({ error: "signature required" }));
100
+ return false;
101
+ }
102
+ // The photo endpoint has a larger body budget; unknown or read-only keys get no read budget.
103
+ if (req.method === "POST" && path === "/session/prompt" && !canUpload(req.headers[HDR.pubkey])) {
104
+ res.writeHead(401, { "content-type": "application/json", "connection": "close" });
105
+ res.end(JSON.stringify({ error: "enrolled phone write scope required" }));
106
+ return false;
107
+ }
95
108
  return true;
96
109
  }
97
110
 
@@ -2,6 +2,8 @@
2
2
  // is MEMORY-ONLY (a restart answers have:0, the desktop resends its window); the prompt queue
3
3
  // lives in state so a restart never strands a queued prompt. Live frames ride pushEventToStreams
4
4
  // (scope-filtered, never logged); only chat.prompt.queued/.delivered/.failed are audit-appended.
5
+ import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
5
7
  import { setInterval } from "node:timers";
6
8
 
7
9
  const CHAT_CAP = 400; // turns kept per project, highest idx wins
@@ -10,11 +12,22 @@ const PROMPT_CAP = 200;
10
12
  const PROMPT_TEXT_MAX = 8000;
11
13
  const EVENT_TEXT_MAX = 500;
12
14
  const PROMPT_TTL_MS = Number(process.env.RELAY_PROMPT_TTL_MS || 120000);
15
+ // Claims cover the desktop's 240s crew timeout plus ack slack; heartbeats cover lock waits.
16
+ const CLAIM_TTL_MS = 300000;
13
17
  const PROMPT_SWEEP_MS = Number(process.env.RELAY_PROMPT_SWEEP_MS || 30000);
14
18
  const EXPIRED_REASON = "No Mac is relaying this project. Is the Trantor app open?";
19
+ const CLAIMED_EXPIRED_REASON = "The Mac claimed this prompt but never delivered it. Try sending it again.";
15
20
  const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent", "stale"]);
16
21
  const ANSWER_PICKS_MAX = 10;
17
22
  const ANSWER_OTHER_MAX = 2000;
23
+ // #12161: photo attachments ride POST /session/prompt as base64 JPEGs. The phone downscales to
24
+ // the byte cap; the hub stores the bytes on disk next to its state (never in bus.json) and hands
25
+ // metadata to the claiming desktop, which downloads them through GET /session/attachment.
26
+ const ATTACHMENTS_MAX = 4;
27
+ const PROJECT_ATTACHMENT_BYTES_MAX = 64 * 1024 * 1024;
28
+ const ATTACHMENT_BYTES_MAX = 2 * 1024 * 1024;
29
+ const ATTACHMENT_B64_MAX = Math.ceil(ATTACHMENT_BYTES_MAX / 3) * 4;
30
+ const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
18
31
  // #12140: crew commands from the phone. The hub validates and queues; the desktop relay runs the
19
32
  // trantor CLI and acks done|failed with the output tail. A seat spec is "agent[:project]".
20
33
  const CREW_SPEC = /^[a-z][a-z0-9-]{0,30}(:[A-Za-z0-9._/-]{1,80})?$/;
@@ -24,7 +37,7 @@ const CREW_CLAUDE_REFUSAL = "The claude seat draws your own Claude quota; start
24
37
  // oxlint-disable-next-line no-control-regex -- stripping ANSI escape sequences IS matching control characters
25
38
  const stripAnsi = (s) => String(s).replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
26
39
 
27
- export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier }) {
40
+ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier, attachmentsDir }) {
28
41
  const now = () => Date.now();
29
42
  const mirrors = new Map(); // project -> { session, total, turns: Map(idx->turn), have, meta, updatedAt, host }
30
43
  state.sessionPrompts ||= [];
@@ -32,21 +45,93 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
32
45
 
33
46
  const findPrompt = (id) => state.sessionPrompts.find(p => p.id === Number(id));
34
47
 
35
- // A prompt still queued past the TTL means no desktop relay claimed it. Expiry is lazy (on
36
- // read) plus a sweep that is a no-op when nothing is queued, so an idle hub does no work.
48
+ // ---- attachments (#12161): bytes live on disk as <id>-<n>.jpg; the row keeps metadata ----
49
+ function attachmentPath(file) { return join(attachmentsDir, file); }
50
+ function unlinkAttachments(p) {
51
+ if (!attachmentsDir) return;
52
+ for (const a of p.attachments || []) { try { unlinkSync(attachmentPath(a.file)); } catch {} }
53
+ }
54
+ // Startup/sweep GC: remove files no queued-or-claimed row references (evicted rows, restarts,
55
+ // lost races). Cheap: a directory listing every sweep.
56
+ function sweepAttachments() {
57
+ if (!attachmentsDir) return;
58
+ let live;
59
+ try { live = new Set(state.sessionPrompts.filter(p => p.status === "queued" || p.status === "claimed")
60
+ .flatMap(p => (p.attachments || []).map(a => a.file))); } catch { return; }
61
+ let entries;
62
+ try { entries = readdirSync(attachmentsDir); } catch { return; }
63
+ for (const name of entries) {
64
+ if (!live.has(name)) { try { unlinkSync(attachmentPath(name)); } catch {} }
65
+ }
66
+ }
67
+ try { mkdirSync(attachmentsDir, { recursive: true }); } catch {}
68
+ sweepAttachments();
69
+ // Validate and decode the wire shape: [{name, data(base64 JPEG)}]. Returns rows for the prompt
70
+ // (files not yet written) or { error }.
71
+ function parseAttachments(raw) {
72
+ if (raw == null) return { attachments: [] };
73
+ if (!Array.isArray(raw) || raw.length > ATTACHMENTS_MAX) return { error: `at most ${ATTACHMENTS_MAX} attachments` };
74
+ const attachments = [];
75
+ for (const a of raw) {
76
+ const name = String(a?.name || "photo.jpg").slice(0, 120);
77
+ // Coerce, then judge the value: any non-base64 or non-JPEG bytes dies at the magic check,
78
+ // so the wire shape never needs a runtime type probe.
79
+ const data = String(a?.data ?? "");
80
+ if (!data.length || data.length > ATTACHMENT_B64_MAX || !/^[A-Za-z0-9+/=\s]+$/.test(data)) {
81
+ return { error: "attachment must be base64 JPEG of 2 MB or less" };
82
+ }
83
+ const bytes = Buffer.from(data, "base64");
84
+ if (!bytes.length || bytes.length > ATTACHMENT_BYTES_MAX || !bytes.subarray(0, 3).equals(JPEG_MAGIC)) {
85
+ return { error: "attachment must be a JPEG of 2 MB or less" };
86
+ }
87
+ attachments.push({ name, size: bytes.length, bytes });
88
+ }
89
+ return { attachments };
90
+ }
91
+ function storeAttachments(id, attachments) {
92
+ if (!attachmentsDir) {
93
+ if (attachments.length) throw new Error("attachment storage is not configured");
94
+ return [];
95
+ }
96
+ mkdirSync(attachmentsDir, { recursive: true });
97
+ return attachments.map((a, n) => {
98
+ const file = `${id}-${n}.jpg`;
99
+ writeFileSync(attachmentPath(file), a.bytes);
100
+ return { name: a.name, size: a.size, file };
101
+ });
102
+ }
103
+
104
+ // Queued work expires quickly; claimed work keeps its bytes while the relay renews its lease.
37
105
  function expireQueued() {
38
106
  let changed = false;
39
107
  for (const p of state.sessionPrompts) {
40
- if (p.status !== "queued" || now() - p.ts <= PROMPT_TTL_MS) continue;
41
- p.status = "expired"; p.outcome = "expired"; p.error = EXPIRED_REASON;
42
- appendEvent("chat.prompt.failed", p.project, p.by, { promptId: p.id, outcome: "expired", error: EXPIRED_REASON });
108
+ const claimed = p.status === "claimed";
109
+ const from = p.status === "queued" ? p.ts : claimed ? (p.renewedAt || p.claimedAt) : 0;
110
+ if (!from || now() - from <= (claimed ? CLAIM_TTL_MS : PROMPT_TTL_MS)) continue;
111
+ p.status = "expired"; p.outcome = "expired";
112
+ p.error = claimed ? CLAIMED_EXPIRED_REASON : EXPIRED_REASON;
113
+ unlinkAttachments(p);
114
+ appendEvent("chat.prompt.failed", p.project, p.by, { promptId: p.id, outcome: "expired", error: p.error });
43
115
  changed = true;
44
116
  }
45
117
  if (changed) markDirty();
46
118
  return changed;
47
119
  }
120
+ // Only terminal rows can be discarded; live work keeps its queue slot and attachment bytes.
121
+ function trimPrompts() {
122
+ while (state.sessionPrompts.length > PROMPT_CAP) {
123
+ const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
124
+ if (ti < 0) break;
125
+ const [evicted] = state.sessionPrompts.splice(ti, 1);
126
+ unlinkAttachments(evicted);
127
+ }
128
+ }
129
+ function queueFull() {
130
+ return state.sessionPrompts.filter(p => p.status === "queued" || p.status === "claimed").length >= PROMPT_CAP;
131
+ }
48
132
  const sweep = setInterval(() => {
49
- if (state.sessionPrompts.some(p => p.status === "queued")) expireQueued();
133
+ sweepAttachments();
134
+ if (state.sessionPrompts.some(p => p.status === "queued" || p.status === "claimed")) expireQueued();
50
135
  }, PROMPT_SWEEP_MS);
51
136
  sweep.unref?.();
52
137
 
@@ -140,10 +225,11 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
140
225
  const isChat = P === "/session/chat";
141
226
  const isPrompts = P === "/session/prompts";
142
227
  const isPrompt = P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack";
228
+ const isAttachment = P === "/session/attachment";
143
229
  const isAnswer = P === "/session/answer";
144
230
  const isCrewCommand = P === "/crew/command";
145
231
  const isCrewRoster = P === "/crew/roster";
146
- if (!isChat && !isPrompts && !isPrompt && !isAnswer && !isCrewCommand && !isCrewRoster) return false;
232
+ if (!isChat && !isPrompts && !isPrompt && !isAttachment && !isAnswer && !isCrewCommand && !isCrewRoster) return false;
147
233
  const { body, json, canon, scopeAllows } = ctx;
148
234
 
149
235
  // #12137: the phone's answer to an AskUserQuestion joins the SAME queue as a plain prompt,
@@ -161,15 +247,13 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
161
247
  const other = String(b.other || "").slice(0, ANSWER_OTHER_MAX);
162
248
  if (!picks.length && !other.trim()) return json(res, 400, { error: "picks or other required" });
163
249
  expireQueued();
250
+ if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
164
251
  state.sessionPromptSeq += 1;
165
252
  const text = other.trim() ? other : `picks ${picks.join(", ")}`;
166
253
  const p = { id: state.sessionPromptSeq, project, kind: "answer", session, toolId, picks, other,
167
254
  text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
168
255
  state.sessionPrompts.push(p);
169
- while (state.sessionPrompts.length > PROMPT_CAP) {
170
- const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
171
- state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
172
- }
256
+ trimPrompts();
173
257
  markDirty();
174
258
  appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "answer", toolId, text: text.slice(0, EVENT_TEXT_MAX) });
175
259
  pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
@@ -201,6 +285,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
201
285
  if (action === "up" && !seats.length) return json(res, 400, { error: "up needs at least one seat" });
202
286
  if (action === "swap" && (!from || !to)) return json(res, 400, { error: "swap needs from and to" });
203
287
  expireQueued();
288
+ if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
204
289
  state.sessionPromptSeq += 1;
205
290
  // down with an empty seat list means the whole project.
206
291
  const text = action === "swap" ? `swap ${from} ${to}` : seats.length ? `${action} ${seats.join(" ")}` : `${action} ${project}`;
@@ -209,10 +294,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
209
294
  if (from) p.from = from;
210
295
  if (to) p.to = to;
211
296
  state.sessionPrompts.push(p);
212
- while (state.sessionPrompts.length > PROMPT_CAP) {
213
- const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
214
- state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
215
- }
297
+ trimPrompts();
216
298
  markDirty();
217
299
  appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "crew", action, text: text.slice(0, EVENT_TEXT_MAX) });
218
300
  pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
@@ -268,26 +350,41 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
268
350
  const text = String(b.text || "");
269
351
  if (!project || !text.trim()) return json(res, 400, { error: "project and non-empty text required" });
270
352
  if (text.length > PROMPT_TEXT_MAX) return json(res, 400, { error: `text must be <= ${PROMPT_TEXT_MAX} chars` });
353
+ const parsed = parseAttachments(b.attachments);
354
+ if (parsed.error) return json(res, 400, { error: parsed.error });
271
355
  expireQueued();
356
+ if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
357
+ const liveBytes = state.sessionPrompts
358
+ .filter(p => p.project === project && (p.status === "queued" || p.status === "claimed"))
359
+ .reduce((total, p) => total + (p.attachments || []).reduce((sum, a) => sum + a.size, 0), 0);
360
+ const incomingBytes = parsed.attachments.reduce((sum, a) => sum + a.bytes.length, 0);
361
+ if (liveBytes + incomingBytes > PROJECT_ATTACHMENT_BYTES_MAX) {
362
+ return json(res, 413, { error: "project queued and claimed attachments exceed 64 MiB" });
363
+ }
272
364
  state.sessionPromptSeq += 1;
273
365
  const p = { id: state.sessionPromptSeq, project, text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
274
- state.sessionPrompts.push(p);
275
- // Cap: evict oldest terminal rows first so live work is never the first casualty.
276
- while (state.sessionPrompts.length > PROMPT_CAP) {
277
- const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
278
- state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
366
+ try {
367
+ p.attachments = storeAttachments(p.id, parsed.attachments);
368
+ } catch (error) {
369
+ return json(res, 500, { error: String(error?.message || error) });
279
370
  }
371
+ state.sessionPrompts.push(p);
372
+ trimPrompts();
280
373
  markDirty();
281
- appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, text: text.slice(0, EVENT_TEXT_MAX) });
374
+ if (p.attachments.length) appendEvent("chat.prompt.queued", project, p.by,
375
+ { promptId: p.id, text: text.slice(0, EVENT_TEXT_MAX), attachments: p.attachments.length });
376
+ else appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, text: text.slice(0, EVENT_TEXT_MAX) });
282
377
  pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
283
378
  return json(res, 200, { id: p.id, status: "queued" });
284
379
  }
285
380
 
286
381
  // The public view of a queue row: plain prompts are kind "prompt"; answers carry their
287
382
  // session, toolId, picks and other; crew commands carry action, seats, from and to —
288
- // so the claiming desktop has everything it needs.
383
+ // so the claiming desktop has everything it needs. Attachments ride as metadata only;
384
+ // the bytes go to the claimer through GET /session/attachment.
289
385
  const promptView = (p) => {
290
386
  const view = { id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts, status: p.status, kind: p.kind || "prompt" };
387
+ if (p.attachments?.length) view.attachments = p.attachments.map(({ name, size }) => ({ name, size }));
291
388
  if (p.kind === "answer") {
292
389
  view.session = p.session; view.toolId = p.toolId; view.picks = p.picks;
293
390
  if (p.other && p.other.trim()) view.other = p.other; // absent, never "", when nothing was typed
@@ -315,6 +412,15 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
315
412
  expireQueued();
316
413
  const p = findPrompt(b.id);
317
414
  if (!p) return json(res, 404, { error: "prompt not found" });
415
+ // Renewal never grants a second delivery; only the original host can extend a live claim.
416
+ if (b.renew === true) {
417
+ if (p.status !== "claimed" || p.host !== String(b.host || "").slice(0, 120)) {
418
+ return json(res, 409, { error: "no matching live claim", status: p.status });
419
+ }
420
+ p.renewedAt = now();
421
+ markDirty();
422
+ return json(res, 200, { ok: true });
423
+ }
318
424
  // The first claim wins; a second claim — even from the same host — gets 409.
319
425
  if (p.status !== "queued") return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
320
426
  p.status = "claimed"; p.host = String(b.host || "").slice(0, 120); p.claimedAt = now();
@@ -327,6 +433,9 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
327
433
  const b = await body(req);
328
434
  const p = findPrompt(b.id);
329
435
  if (!p) return json(res, 404, { error: "prompt not found" });
436
+ if (p.status !== "claimed" || p.host !== b.host) {
437
+ return json(res, 409, { error: "no matching live claim", status: p.status });
438
+ }
330
439
  const outcome = String(b.outcome || "");
331
440
  // #12140: a crew command acks done|failed with the CLI output tail, and the status IS the
332
441
  // outcome — never "delivered". Other kinds keep the delivered/blocked/… vocabulary.
@@ -335,9 +444,6 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
335
444
  } else if (!ACK_OUTCOMES.has(outcome)) {
336
445
  return json(res, 400, { error: `outcome must be one of: ${[...ACK_OUTCOMES].join(", ")}` });
337
446
  }
338
- if (p.status === "delivered" || p.status === "done" || p.status === "failed" || p.status === "expired") {
339
- return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
340
- }
341
447
  const error = String(b.error || "").slice(0, EVENT_TEXT_MAX);
342
448
  p.outcome = outcome; p.error = error; p.ackedAt = now();
343
449
  if (p.kind === "crew") {
@@ -346,6 +452,8 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
346
452
  } else {
347
453
  p.status = outcome === "delivered" ? "delivered" : "failed";
348
454
  }
455
+ // A terminal row will never be claimed or downloaded again: its bytes go now.
456
+ unlinkAttachments(p);
349
457
  markDirty();
350
458
  const extra = { promptId: p.id, outcome };
351
459
  if (error) extra.error = error;
@@ -361,6 +469,21 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
361
469
  return json(res, 200, { id: p.id, status: p.status, outcome: p.outcome || "", output: p.output || "", error: p.error || "" });
362
470
  }
363
471
 
472
+ // The claiming desktop fetches one attachment's bytes. Owner-gated in auth.mjs, absent from
473
+ // the public listener's allowlist — phones upload, they never read back.
474
+ if (req.method === "GET" && P === "/session/attachment") {
475
+ const p = findPrompt(q.id);
476
+ const n = Number(q.n);
477
+ const meta = p && Number.isInteger(n) ? (p.attachments || [])[n] : null;
478
+ let bytes = null;
479
+ if (meta && attachmentsDir) { try { bytes = readFileSync(attachmentPath(meta.file)); } catch {} }
480
+ if (!bytes) return json(res, 404, { error: "attachment not found" });
481
+ if (res.headersSent || res.writableEnded || res.destroyed) return true;
482
+ res.writeHead(200, { "content-type": "image/jpeg", "content-length": bytes.length });
483
+ res.end(bytes);
484
+ return true;
485
+ }
486
+
364
487
  return false;
365
488
  }
366
489
 
package/hub.mjs CHANGED
@@ -134,6 +134,7 @@ const context = {
134
134
  const sessionChat = createSessionRuntime({
135
135
  state: store.state, markDirty: store.markDirty,
136
136
  appendEvent: events.appendEvent, pushEventToStreams: events.pushEventToStreams, notifier,
137
+ attachmentsDir: join(DATA_DIR, "attachments"),
137
138
  });
138
139
  const routes = [routeDevices, routeAdmin, routeCards, routeInsights, routeMessages, sessionChat.route];
139
140
 
@@ -150,14 +151,27 @@ function requestHandler(runtime, ctx, gate = null) {
150
151
  const q = Object.fromEntries(u.searchParams);
151
152
  const P = u.pathname;
152
153
  if (gate && !gate.admit(req, res, P)) return;
154
+ const cap = gate ? runtime.bodyCap(P) : runtime.ownerBodyCap(req, P);
155
+ let ownerAuth;
156
+ if (req.method === "POST") {
157
+ const raw = await runtime.rawBody(req, cap);
158
+ if (raw === undefined) return runtime.json(res, 413, { error: `request body over the ${cap} byte cap` });
159
+ if (req._bodyBytes > runtime.bodyCap(P)) {
160
+ ownerAuth = await runtime.authenticate(req, runtime.authPath(u), true);
161
+ if (!ownerAuth.ok) return runtime.json(res, ownerAuth.code || 401, { error: ownerAuth.error });
162
+ }
163
+ }
153
164
  const b0 = req.method === "POST" ? await runtime.body(req) : {};
165
+ if (ownerAuth && !runtime.scopeAllows(ownerAuth.identity, runtime.projectFromRequest(P, q, b0, req.method), "owner")) {
166
+ return runtime.json(res, 403, { error: "large request body requires a project owner" });
167
+ }
154
168
  if (runtime.handleEnrollment({ req, res, P, u, b0, inviteOnly: !!gate })) return;
155
- const auth = runtime.PUBLIC_ENDPOINTS.has(P)
169
+ const auth = ownerAuth || (runtime.PUBLIC_ENDPOINTS.has(P)
156
170
  ? { ok: true, mode: ctx.AUTH_MODE, trusted: false }
157
- : await runtime.authenticate(req, runtime.authPath(u));
171
+ : await runtime.authenticate(req, runtime.authPath(u)));
158
172
  if (runtime.handleInvite({ req, res, P, auth, b0 })) return;
159
173
  if (!auth.ok) return runtime.json(res, auth.code || 401, { error: auth.error || "unauthorized" });
160
- const authorization = runtime.authorize(auth, req.method, P, runtime.projectFromRequest(P, q, b0));
174
+ const authorization = runtime.authorize(auth, req.method, P, runtime.projectFromRequest(P, q, b0, req.method));
161
175
  if (!authorization.ok) return runtime.json(res, authorization.code || 403, { error: authorization.error || "forbidden" });
162
176
  if (gate && P === "/stream" && !gate.openStream(req, res)) return;
163
177
  if (req.method === "GET" && P === "/_op") {
@@ -181,6 +195,8 @@ server.listen(PORT, HOST, () => console.error(`[trantor] hub on http://${HOST}:$
181
195
 
182
196
  if (process.env.RELAY_PUBLIC_PORT) {
183
197
  const publicPort = Number(process.env.RELAY_PUBLIC_PORT);
184
- const publicServer = http.createServer(requestHandler(publicAuth, publicContext, createPublicGate()));
198
+ const publicGate = createPublicGate({ canUpload: pubkey =>
199
+ publicAuth.scopeAllows(publicAuth.findIdentity(pubkey), "", "write") });
200
+ const publicServer = http.createServer(requestHandler(publicAuth, publicContext, publicGate));
185
201
  publicServer.listen(publicPort, "127.0.0.1", () => console.error(`[trantor] public hub on http://127.0.0.1:${publicServer.address().port} (auth: enforce)`));
186
202
  }
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
- // The runner writes ~/.agent-bus/turnstamp-<seat>-<project>.json at turn start and
113
- // unlinks it on every exit — a stamp younger than the scratch window IS a live (or
114
- // just-crashed) turn; a stamp that old cannot be one, since the time box kills in hours.
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 st = fs.statSync(turnStampPath(home, project, seat));
122
- return now - Number(st.mtimeMs) < SCRATCH_IDLE_MAX;
123
- } catch { return false; }
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.worktreesRoot || join(home, ".agent-bus", "worktrees");
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 isLive = live.some((l) => l.includes(wt)) || seatTurnLive(fs, home, now, proj.name, seat.name);
141
- const liveReason = live.some((l) => l.includes(wt))
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) would free —
370
- // the summary line's number. simctl/opencode items free space inside their own stores.
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) => p.type === "build-dir" || p.type === "scratch-entry").reduce((n, p) => n + (p.bytes || 0), 0);
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. simctl/opencode run as their own CLIs (injected in tests);
412
- // build dirs are removed by the injected `remove` (default fs.rmSync) only after
413
- // assertUnderHome AND an is-under-worktrees-root re-check on the exact path.
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 === "build-dir" || item.type === "scratch-entry") {
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, join(home, ".agent-bus", "worktrees"))) throw new Error(`refused: ${p} is not under the seat worktrees root`);
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>--weekly</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
- const clean = d.args?.includes("--clean");
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) : [];
@@ -66,10 +66,24 @@ export function ensureSeatWorktree({ sourceDir, project, agent, home = homedir()
66
66
  }
67
67
  }
68
68
 
69
- const r = spawnSync("git", ["-C", root, "worktree", "add", "--no-track", "-B", branch, seatDir, "HEAD"], {
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.116",
3
+ "version": "0.18.118",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"