trantor 0.17.61 → 0.17.63

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.17.61",
3
+ "version": "0.17.63",
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/README.md CHANGED
@@ -51,7 +51,7 @@ heartbeats, inbox delivery, handoff/baton pass, sub-agent cards):
51
51
  "relay": {
52
52
  "command": "node",
53
53
  "args": ["<absolute-path-to-trantor>/mcp.mjs"],
54
- "env": { "RELAY_URL": "http://127.0.0.1:4477", "RELAY_AGENT": "kimi" },
54
+ "env": { "RELAY_URL": "http://127.0.0.1:4477", "RELAY_AGENT": "kimi-orch" },
55
55
  "startupTimeoutMs": 15000,
56
56
  "toolTimeoutMs": 150000
57
57
  }
@@ -68,6 +68,11 @@ runs your live checkout, so the relay server itself never goes stale. Invoke the
68
68
  `/skill:crew`, `/skill:handoff`, `/skill:research`. Set `TRANTOR_DEBUG_HOOKS=1` on the `kimi`
69
69
  process to dump raw hook payloads to `~/.agent-bus/kimi-hook-debug.jsonl`.
70
70
 
71
+ The orchestrator's bus identity is `kimi-orch:<project>` — deliberately distinct from `kimi:<project>`,
72
+ which belongs to a kimi CREW SEAT (`trantor up kimi`). Same doctrine as the openrouter seat label:
73
+ one bus peer per role, so an orchestrator and its own kimi seat never share a heartbeat, inbox, or
74
+ card attribution.
75
+
71
76
  That's it. (Prefer source? `git clone https://github.com/sashabogi/trantor && cd trantor &&
72
77
  npm install && bash deploy/setup.sh` — identical result.)
73
78
 
package/bin/app.mjs ADDED
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // trantor app — install/update the Trantor DESKTOP APP (Tauri) from GitHub Releases.
3
+ //
4
+ // The npm package deliberately does NOT ship desktop/ (a 6MB DMG has no business in node_modules);
5
+ // the app travels as a GitHub Release asset instead. This command is the whole distribution story
6
+ // for a teammate: `npm i -g trantor && trantor app install` → latest DMG lands in /Applications.
7
+ //
8
+ // trantor app status: installed version vs latest release
9
+ // trantor app install download the latest release DMG and install to /Applications
10
+ // trantor app update same as install (re-pulls whatever is latest)
11
+ //
12
+ // Release side (maintainer): build the DMG (cd desktop && npm run tauri build), then
13
+ // gh release create app-v<ver> desktop/src-tauri/target/release/bundle/dmg/Trantor_<ver>_aarch64.dmg
14
+ // Any release whose assets include a Trantor_*.dmg is an app release; the newest one wins, so app
15
+ // releases interleave freely with code (npm) releases.
16
+ import { execFileSync } from "node:child_process";
17
+ import { createWriteStream, existsSync, rmSync } from "node:fs";
18
+ import { Readable } from "node:stream";
19
+ import { pipeline } from "node:stream/promises";
20
+ import { join } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+
23
+ const REPO = "sashabogi/trantor";
24
+ const APP = "/Applications/Trantor.app";
25
+ const ARCH_TAG = process.arch === "arm64" ? "aarch64" : "x64";
26
+ const cmd = process.argv[2] || "status";
27
+
28
+ if (process.platform !== "darwin") { console.error("trantor app: the desktop app is macOS-only for now"); process.exit(1); }
29
+ if (!["status", "install", "update"].includes(cmd)) {
30
+ console.error("usage: trantor app [status|install|update]"); process.exit(1);
31
+ }
32
+
33
+ function sh(file, args) { return execFileSync(file, args, { encoding: "utf8" }); }
34
+
35
+ function installedVersion() {
36
+ try { return sh("plutil", ["-extract", "CFBundleShortVersionString", "raw", join(APP, "Contents/Info.plist")]).trim(); }
37
+ catch { return ""; }
38
+ }
39
+
40
+ // Newest release carrying a Trantor DMG for this arch (falls back to any Trantor DMG — old
41
+ // releases may predate multi-arch naming). GITHUB_TOKEN is honored but not required (public repo).
42
+ async function latestAppRelease() {
43
+ const headers = { accept: "application/vnd.github+json", "user-agent": "trantor-app" };
44
+ if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
45
+ const r = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=30`, { headers, signal: AbortSignal.timeout(15000) });
46
+ if (!r.ok) throw new Error(`GitHub API ${r.status} — ${(await r.text()).slice(0, 200)}`);
47
+ const isDmg = a => /^Trantor[_-].*\.dmg$/.test(a.name);
48
+ for (const rel of await r.json()) {
49
+ const assets = (rel.assets || []).filter(isDmg);
50
+ if (!assets.length) continue;
51
+ const asset = assets.find(a => a.name.includes(`_${ARCH_TAG}`)) || assets[0];
52
+ if (!asset.name.includes(`_${ARCH_TAG}`)) console.error(`⚠ no ${ARCH_TAG} build in ${rel.tag_name} — using ${asset.name} (may not run on this Mac)`);
53
+ const version = (asset.name.match(/[_-]([0-9]+(?:\.[0-9]+)*)[_-]/) || [])[1] || rel.tag_name.replace(/^app-v?|^v/, "");
54
+ return { tag: rel.tag_name, version, asset };
55
+ }
56
+ throw new Error("no release with a Trantor DMG asset found");
57
+ }
58
+
59
+ const rel = await latestAppRelease().catch(e => { console.error(`trantor app: ${e.message}`); process.exit(1); });
60
+ const have = installedVersion();
61
+
62
+ if (cmd === "status") {
63
+ console.log(`installed: ${have ? `${have} (${APP})` : "not installed"}`);
64
+ console.log(`latest: ${rel.version} (${rel.tag} · ${rel.asset.name})`);
65
+ console.log(have === rel.version ? "up to date." : `run \`trantor app install\` to get ${rel.version}.`);
66
+ process.exit(0);
67
+ }
68
+
69
+ console.log(`↓ ${rel.asset.name} (${(rel.asset.size / 1e6).toFixed(1)}MB) from ${rel.tag}…`);
70
+ const dmg = join(tmpdir(), rel.asset.name);
71
+ const dl = await fetch(rel.asset.browser_download_url, { headers: { "user-agent": "trantor-app" }, signal: AbortSignal.timeout(300000) });
72
+ if (!dl.ok || !dl.body) { console.error(`download failed: HTTP ${dl.status}`); process.exit(1); }
73
+ await pipeline(Readable.fromWeb(dl.body), createWriteStream(dmg));
74
+
75
+ let mount = "";
76
+ try {
77
+ // -nobrowse keeps the volume out of Finder; mount point is the last tab-field of the last line.
78
+ const out = sh("hdiutil", ["attach", "-nobrowse", "-readonly", dmg]);
79
+ mount = (out.trim().split("\n").pop() || "").split("\t").pop().trim();
80
+ const src = join(mount, "Trantor.app");
81
+ if (!mount.startsWith("/Volumes/") || !existsSync(src)) throw new Error(`unexpected DMG layout (mount: ${mount || "none"})`);
82
+ if (existsSync(APP)) { console.log(`replacing ${APP} (was ${have || "unknown"})`); rmSync(APP, { recursive: true, force: true }); }
83
+ sh("ditto", [src, APP]);
84
+ // The download carries quarantine; the user explicitly asked for this install — clear it so
85
+ // Gatekeeper doesn't refuse the unsigned build on first launch.
86
+ try { sh("xattr", ["-dr", "com.apple.quarantine", APP]); } catch {}
87
+ console.log(`✓ Trantor.app ${installedVersion() || rel.version} installed → ${APP}`);
88
+ } catch (e) {
89
+ console.error(`install failed: ${e.message}`); process.exitCode = 1;
90
+ } finally {
91
+ if (mount) try { sh("hdiutil", ["detach", mount, "-quiet"]); } catch {}
92
+ try { rmSync(dmg, { force: true }); } catch {}
93
+ }
package/bin/cli.mjs CHANGED
@@ -72,6 +72,8 @@ switch (cmd) {
72
72
  case "policy": run("bin/policy.mjs"); break;
73
73
  case "inbox": run("bin/inbox.mjs"); break;
74
74
  case "duty": run("bin/duty.mjs"); break;
75
+ case "app": run("bin/app.mjs"); break;
76
+ case "patrol": run("bin/patrol.mjs"); break;
75
77
  case "identity": {
76
78
  const { load, publicView, generate, keyPath } = await import(join(ROOT, "lib/identity.mjs"));
77
79
  const sub = args[0], name = args[1] || "human";
@@ -153,7 +155,9 @@ switch (cmd) {
153
155
  trantor models browse live models behind each seat + the router's pick per difficulty
154
156
  trantor up … spawn a crew here: trantor up codex kimi deepseek:deepseek glm:zai-coding-plan
155
157
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
158
+ trantor prune drop dead crew-window tracking rows (ghost workspaces/panes) without spawning anything
156
159
  trantor ui open the live dashboard (board + flow views)
160
+ trantor app the DESKTOP app: status | install | update — pulls the latest DMG from GitHub Releases
157
161
  trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
158
162
  trantor agents what this session's sub-agents did (task · returned? · files written · survived on disk) — [<sessionId>] [--json]
159
163
  trantor gates verification gates: "must verify before shipping" claims that survive handoffs — [--all] [--json]
@@ -169,6 +173,7 @@ switch (cmd) {
169
173
  trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
170
174
  trantor policy the autonomy ladder: show | set <project> <1-4> | link <a> <b> --reason "<why>"
171
175
  trantor duty the always-on fleet duty agent: up | down | status — hub-escalated triage so you are not the switchboard
176
+ trantor patrol machine-wide resource sweep: crews/runners/workspaces/orphans — [--json] [--reap] (reap = dead rows + stale artifacts ONLY)
172
177
 
173
178
  Claude Code plugin (the orchestrator side):
174
179
  claude plugin marketplace add sashabogi/trantor && claude plugin install trantor
package/bin/crew.sh CHANGED
@@ -245,16 +245,17 @@ case "$CMD" in up|swap|prune) ;; *) echo "usage: crew.sh up <agent...> | crew.sh
245
245
  prune_dead_state() {
246
246
  [ -f "$STATE" ] || return 0
247
247
  [ "$DRY" = "1" ] && return 0
248
- local CLIVE=""
248
+ # CLIVE = live workspace uuids · CLIVE_NAMES = their titles. Seat (`cmux`) rows are validated at
249
+ # WORKSPACE granularity — is a live workspace named trantor:<their project> still up? — NOT per
250
+ # surface: `list-pane-surfaces --workspace` only returns the FIRST pane's surfaces, so a
251
+ # per-surface check reaps every split-created live seat's row (bug shipped 0.17.61, caught
252
+ # 2026-08-07 when it stripped two live crews' seat rows).
253
+ local CLIVE="" CLIVE_NAMES=""
249
254
  if _cmux_ok; then
250
- local wids wid
251
- wids="$(_cmux workspace list --id-format both --json 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));const a=Array.isArray(o)?o:(o.workspaces||[]);console.log(a.map(x=>x.id).filter(Boolean).join(" "))}catch(e){}})')"
252
- if [ -n "$wids" ]; then
253
- CLIVE="$wids"
254
- for wid in $wids; do
255
- CLIVE="$CLIVE $(_cmux list-pane-surfaces --workspace "$wid" --id-format uuids --json 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));const a=o.surfaces||o.panes||o;console.log((Array.isArray(a)?a:[]).map(s=>s.id||s.surface_id).filter(Boolean).join(" "))}catch(e){}})')"
256
- done
257
- fi
255
+ local pair
256
+ pair="$(_cmux workspace list --id-format both --json 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));const a=Array.isArray(o)?o:(o.workspaces||[]);console.log(a.map(x=>x.id).filter(Boolean).join(" "));console.log(""+a.map(x=>x.custom_title||x.name||"").filter(Boolean).join("")+"")}catch(e){}})')"
257
+ CLIVE="$(printf '%s' "$pair" | sed -n 1p)"
258
+ CLIVE_NAMES="$(printf '%s' "$pair" | sed -n 2p)"
258
259
  fi
259
260
  local tmp="$STATE.tmp" line alive
260
261
  : > "$tmp"
@@ -266,8 +267,11 @@ prune_dead_state() {
266
267
  [ -n "$(osascript -e "tell application \"Terminal\" to get id of (first window whose id is $RH)" 2>/dev/null)" ] || alive=0
267
268
  elif [ "$RK" = "tmux" ]; then
268
269
  tmux has-session -t "trantor:$RP" 2>/dev/null || alive=0
269
- elif [ "$RK" = "cmuxws" ] || [ "$RK" = "cmux" ]; then
270
+ elif [ "$RK" = "cmuxws" ]; then
270
271
  if [ -n "$CLIVE" ]; then case " $CLIVE " in *" $RH "*) : ;; *) alive=0 ;; esac; fi
272
+ elif [ "$RK" = "cmux" ]; then
273
+ # seat row lives exactly as long as its project still has a live crew workspace
274
+ if [ -n "$CLIVE" ]; then case "$CLIVE_NAMES" in *$'\x01'"trantor:$RP"$'\x01'*) : ;; *) alive=0 ;; esac; fi
271
275
  fi
272
276
  [ "$alive" = "1" ] && printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
273
277
  done < "$STATE"
package/bin/duty.mjs CHANGED
@@ -43,7 +43,7 @@ function fleetHub() {
43
43
  const AGENT = val("agent", "claude");
44
44
  const SESSION = `${AGENT}:fleet`;
45
45
 
46
- const RULES = `Rules: you are ${SESSION}, the trantor fleet DUTY AGENT — the always-on triage seat. You NEVER write code and NEVER edit project files. On every wake: (1) read the message(s) that woke you; (2) triage with your relay tools — relay_peers for who is live/down, relay_board with the project param for any board, relay_inbox for your own backlog; runner logs live at ~/.agent-bus/logs/<agent>-<project>.jsonl if a seat looks dead; (3) ACT: an UNDELIVERED escalation means the recipient is idle, deaf (wrong hub / stale hooks — a known failure mode), or gone — relay the content to a live session that can act, wake a crew seat with a direct message, or post the information into the project lane so the human's app notifies them; an OVERSEER warning means two parties may collide — message them to coordinate; a seat reported down/errored — check its log tail and either resend its contract or report exactly what is needed. (4) Report each action in ONE bus message (<280 chars) to the lane it concerns. If only a human can decide, say exactly that, in that lane, once. Then END YOUR TURN — the runner wakes you for the next event.`;
46
+ const RULES = `Rules: you are ${SESSION}, the trantor fleet DUTY AGENT — the always-on triage seat. You NEVER write code and NEVER edit project files. On every wake: (1) read the message(s) that woke you; (2) patrol: run \`node ${ROOT}/bin/patrol.mjs --json\`; reap only when an orphan is provably dead, and DM sasha about anything ambiguous such as a live orphan runner or dev server older than 24h; (3) triage with your relay tools — relay_peers for who is live/down, relay_board with the project param for any board, relay_inbox for your own backlog; runner logs live at ~/.agent-bus/logs/<agent>-<project>.jsonl if a seat looks dead; (4) ACT: an UNDELIVERED escalation means the recipient is idle, deaf (wrong hub / stale hooks — a known failure mode), or gone — relay the content to a live session that can act, wake a crew seat with a direct message, or post the information into the project lane so the human's app notifies them; an OVERSEER warning means two parties may collide — message them to coordinate; a seat reported down/errored — check its log tail and either resend its contract or report exactly what is needed. (5) Report each action and patrol summary in ONE bus message (<280 chars) to the lane it concerns. If only a human can decide, say exactly that, in that lane, once. Then END YOUR TURN — the runner wakes you for the next event.`;
47
47
 
48
48
  const KICKOFF = `You are ${SESSION}, the fleet duty agent, freshly started. Do a short patrol: relay_peers (note anything down/errored), then relay_inbox. Handle what is actionable per the Rules, post one line to the bus saying the duty seat is on watch, and end your turn.\n\n${RULES}`;
49
49
 
package/bin/patrol.mjs ADDED
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env node
2
+ // trantor patrol — machine-wide crew/resource report. It never kills live processes.
3
+ import { readdirSync, rmSync, statSync } from "node:fs";
4
+ import { join, basename, dirname, resolve, sep } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+
8
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const DAY = 24 * 60 * 60 * 1000;
10
+
11
+ const emptyResources = {
12
+ inventory: () => ({ rows: [], runners: [], workspaces: [], devServers: [] }),
13
+ cleanDead: () => "",
14
+ };
15
+
16
+ function busDir(env = process.env) {
17
+ if (env.AGENT_BUS_DIR) return env.AGENT_BUS_DIR;
18
+ return join(env.RELAY_DATA_DIR || homedir(), ".agent-bus");
19
+ }
20
+
21
+ function isUnderDir(path, parent) {
22
+ if (!path || !parent) return false;
23
+ const child = resolve(String(path));
24
+ const root = resolve(String(parent));
25
+ return child === root || child.startsWith(root.endsWith(sep) ? root : root + sep);
26
+ }
27
+
28
+ function isBusInternalRunner(runner, bus) {
29
+ return isUnderDir(runner?.dir, bus);
30
+ }
31
+
32
+ function safeArray(v) { return Array.isArray(v) ? v : []; }
33
+ function displayProject(p) { return p || "<legacy>"; }
34
+ function runnerProject(r) { return String(r?.project || (r?.dir ? basename(String(r.dir)) : "") || ""); }
35
+ function workspaceProject(w) {
36
+ const m = /^trantor:([^/]+)$/.exec(String(w?.title || ""));
37
+ return m ? m[1] : "";
38
+ }
39
+ function rowProject(row) { return String(row?.project || ""); }
40
+ function rowMatchesRunner(row, runner) {
41
+ if (String(row?.agent || "") !== String(runner?.agent || "")) return false;
42
+ const rp = rowProject(row);
43
+ return !rp || rp === runnerProject(runner);
44
+ }
45
+
46
+ function sortedProjects(projects) {
47
+ return [...projects].sort((a, b) => displayProject(a).localeCompare(displayProject(b)));
48
+ }
49
+
50
+ export function buildPatrolReport(rawInventory = {}, reaped = [], { bus = busDir() } = {}) {
51
+ const rows = safeArray(rawInventory.rows);
52
+ const runners = safeArray(rawInventory.runners);
53
+ const workspaces = safeArray(rawInventory.workspaces);
54
+ const devServers = safeArray(rawInventory.devServers);
55
+ const projects = new Set();
56
+ for (const row of rows) projects.add(rowProject(row));
57
+ for (const runner of runners) projects.add(runnerProject(runner));
58
+ for (const ws of workspaces) {
59
+ const p = workspaceProject(ws);
60
+ if (p) projects.add(p);
61
+ }
62
+ for (const dev of devServers) if (dev?.project) projects.add(String(dev.project));
63
+
64
+ const out = {};
65
+ for (const p of sortedProjects(projects)) out[p] = { rows: [], runners: [], workspaces: [], devServers: [] };
66
+ const ensure = (p) => (out[p] ||= { rows: [], runners: [], workspaces: [], devServers: [] });
67
+
68
+ for (const row of rows) ensure(rowProject(row)).rows.push(row);
69
+ for (const runner of runners) ensure(runnerProject(runner)).runners.push(runner);
70
+ for (const ws of workspaces) {
71
+ const p = workspaceProject(ws);
72
+ if (p) ensure(p).workspaces.push(ws);
73
+ }
74
+ for (const dev of devServers) if (dev?.project) ensure(String(dev.project)).devServers.push(dev);
75
+
76
+ const workspaceIds = new Set(workspaces.map(w => String(w?.id || "")).filter(Boolean));
77
+ const orphans = [];
78
+ const ambiguous = [];
79
+
80
+ for (const runner of runners) {
81
+ if (isBusInternalRunner(runner, bus)) continue;
82
+ const p = runnerProject(runner);
83
+ if (!p) {
84
+ ambiguous.push({ type: "runner-without-project", pid: runner.pid, agent: runner.agent, dir: runner.dir });
85
+ } else if (!rows.some(row => rowMatchesRunner(row, runner))) {
86
+ orphans.push({ type: "live-runner-without-row", project: p, agent: runner.agent, pid: runner.pid, dir: runner.dir });
87
+ }
88
+ }
89
+
90
+ for (const ws of workspaces) {
91
+ const p = workspaceProject(ws);
92
+ if (!p) {
93
+ ambiguous.push({ type: "non-trantor-workspace", id: ws.id, title: ws.title });
94
+ } else if (!runners.some(r => runnerProject(r) === p)) {
95
+ orphans.push({ type: "workspace-without-live-runner", project: p, id: ws.id, title: ws.title });
96
+ }
97
+ }
98
+
99
+ for (const row of rows) {
100
+ const p = rowProject(row);
101
+ const kind = String(row?.kind || "");
102
+ let live = runners.some(runner => rowMatchesRunner(row, runner));
103
+ if (kind === "cmuxws") live = workspaceIds.has(String(row?.handle || ""));
104
+ if (!live) {
105
+ orphans.push({
106
+ type: "dead-tracking-row",
107
+ project: p,
108
+ kind: row.kind,
109
+ agent: row.agent,
110
+ handle: row.handle,
111
+ });
112
+ } else if (!p) {
113
+ ambiguous.push({ type: "legacy-tracking-row", kind: row.kind, agent: row.agent, handle: row.handle });
114
+ }
115
+ }
116
+
117
+ for (const p of Object.keys(out)) {
118
+ const project = out[p];
119
+ project.counts = {
120
+ rows: project.rows.length,
121
+ runners: project.runners.length,
122
+ workspaces: project.workspaces.length,
123
+ devServers: project.devServers.length,
124
+ };
125
+ }
126
+
127
+ return { projects: out, orphans, ambiguous, reaped };
128
+ }
129
+
130
+ function oldEnough(path, now, maxAgeMs) {
131
+ try { return now - statSync(path).mtimeMs > maxAgeMs; } catch { return false; }
132
+ }
133
+
134
+ function liveSeatFiles(runners) {
135
+ const keys = new Set();
136
+ for (const r of safeArray(runners)) {
137
+ const p = runnerProject(r);
138
+ const agent = String(r?.agent || "");
139
+ if (p && agent) keys.add(`${p}-${agent}.sh`);
140
+ }
141
+ return keys;
142
+ }
143
+
144
+ export function reapStaleArtifacts({ bus = busDir(), runners = [], now = Date.now(), remove = rmSync } = {}) {
145
+ const reaped = [];
146
+ const seats = join(bus, "seats");
147
+ const liveSeats = liveSeatFiles(runners);
148
+ try {
149
+ for (const name of readdirSync(seats)) {
150
+ if (!name.endsWith(".sh")) continue;
151
+ const path = join(seats, name);
152
+ if (liveSeats.has(name) || !oldEnough(path, now, 14 * DAY)) continue;
153
+ remove(path, { force: true });
154
+ reaped.push({ type: "seat-script", path, reason: "no matching live runner and mtime >14d" });
155
+ }
156
+ } catch {}
157
+
158
+ try {
159
+ for (const name of readdirSync(bus)) {
160
+ const startup = /^kimi-startup-.*\.txt\.consumed$/.test(name) || /^startup-.*\.txt\.consumed$/.test(name);
161
+ if (!startup) continue;
162
+ const path = join(bus, name);
163
+ if (!oldEnough(path, now, 7 * DAY)) continue;
164
+ remove(path, { force: true });
165
+ reaped.push({ type: "startup-stash", path, reason: "consumed startup stash mtime >7d" });
166
+ }
167
+ } catch {}
168
+ return reaped;
169
+ }
170
+
171
+ export async function loadResources() {
172
+ try {
173
+ return await import(pathToFileURL(join(ROOT, "hooks/lib/resources.mjs")).href);
174
+ } catch {
175
+ return emptyResources;
176
+ }
177
+ }
178
+
179
+ function humanList(items, fmt, empty = "none") {
180
+ return items.length ? items.map(fmt).join(", ") : empty;
181
+ }
182
+
183
+ export function formatHuman(report) {
184
+ const lines = ["trantor patrol report"];
185
+ const projectNames = sortedProjects(Object.keys(report.projects || {}));
186
+ if (projectNames.length === 0) lines.push("projects: none");
187
+ for (const p of projectNames) {
188
+ const project = report.projects[p];
189
+ lines.push(`\nproject ${displayProject(p)}`);
190
+ lines.push(` rows: ${humanList(project.rows, r => `${r.agent || "?"}/${r.kind || "?"}:${r.handle || "?"}`)}`);
191
+ lines.push(` live runners: ${humanList(project.runners, r => `${r.agent || "?"}(pid ${r.pid || "?"})`)}`);
192
+ lines.push(` cmux workspaces: ${humanList(project.workspaces, w => `${w.title || "?"}:${w.id || "?"}`)}`);
193
+ if (project.devServers?.length) lines.push(` dev servers: ${humanList(project.devServers, d => `${d.pid || "?"} ${d.cmd || ""}`.trim())}`);
194
+ }
195
+ lines.push(`\norphans: ${report.orphans.length}`);
196
+ for (const item of report.orphans) lines.push(` - ${item.type}: ${displayProject(item.project)} ${item.agent || item.title || item.handle || item.id || ""}`.trimEnd());
197
+ lines.push(`ambiguous: ${report.ambiguous.length}`);
198
+ for (const item of report.ambiguous) lines.push(` - ${item.type}: ${item.agent || item.title || item.dir || item.id || ""}`.trimEnd());
199
+ lines.push(`reaped: ${report.reaped.length}`);
200
+ for (const item of report.reaped) lines.push(` - ${item.type}: ${item.path || item.output || ""}`.trimEnd());
201
+ return `${lines.join("\n")}\n`;
202
+ }
203
+
204
+ export async function runPatrol({ args = [], resources = null, env = process.env, now = Date.now(), remove = rmSync } = {}) {
205
+ const json = args.includes("--json");
206
+ const reap = args.includes("--reap");
207
+ const res = resources || await loadResources();
208
+ let inv = { rows: [], runners: [], workspaces: [], devServers: [] };
209
+ try { inv = await res.inventory?.(null) || inv; } catch {}
210
+ const reaped = [];
211
+ if (reap) {
212
+ try {
213
+ const output = await res.cleanDead?.(null);
214
+ if (output) reaped.push({ type: "cleanDead", output: String(output).trim() });
215
+ } catch {}
216
+ reaped.push(...reapStaleArtifacts({ bus: busDir(env), runners: inv.runners, now, remove }));
217
+ }
218
+ const report = buildPatrolReport(inv, reaped, { bus: busDir(env) });
219
+ return json ? `${JSON.stringify(report, null, 2)}\n` : formatHuman(report);
220
+ }
221
+
222
+ if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
223
+ runPatrol({ args: process.argv.slice(2) })
224
+ .then(s => process.stdout.write(s))
225
+ .catch(() => process.stdout.write(formatHuman(buildPatrolReport())));
226
+ }
@@ -0,0 +1,167 @@
1
+ // trantor — resource inventory (INTERSESSION-OPS-CONTRACT #4214). PURE DETECTION: what crew
2
+ // tracking rows exist, which crew runners are actually alive, which cmux workspaces are open,
3
+ // which dev servers run under a directory. Sessions ADOPT live crews; boots clean the provably
4
+ // dead. Provably dead = no live process AND no bus heartbeat AND no owning session — one signal
5
+ // is never proof, and NOTHING here ever kills a process. The only mutation is cleanDead(), which
6
+ // shells `crew.sh prune` (drops dead TRACKING ROWS, never processes).
7
+ //
8
+ // Hard rules (contract-frozen): every export is fail-silent ([] / "" on any error, never throws)
9
+ // and every subprocess has a ≤2s timeout. Hooks run inside the user's tool loop — a throw or a
10
+ // hang breaks a session.
11
+ import { execFileSync } from "node:child_process";
12
+ import { readFileSync, existsSync } from "node:fs";
13
+ import { join, basename, dirname } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { fileURLToPath } from "node:url";
16
+ import { gitRoot } from "../../lib/project.mjs";
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url)); // <pkg>/hooks/lib
19
+ const PKGROOT = join(HERE, "..", "..");
20
+ const CMUX_APP_BIN = "/Applications/cmux.app/Contents/Resources/bin/cmux";
21
+ const TIMEOUT = 2000; // contract: every subprocess ≤2s
22
+
23
+ const busDir = () => process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus");
24
+
25
+ // Run a subprocess, return stdout; "" on ANY failure (missing binary, nonzero exit, timeout).
26
+ function run(cmd, args, env = {}) {
27
+ try {
28
+ return execFileSync(cmd, args, {
29
+ encoding: "utf8", timeout: TIMEOUT,
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ env: { ...process.env, ...env },
32
+ });
33
+ } catch { return ""; }
34
+ }
35
+
36
+ // Same, but reports ENOENT separately so callers can fall back to an alternate binary path
37
+ // WITHOUT masking a real failure of a binary that exists (a cmux that ran and said "socket off"
38
+ // must yield [], not trigger a retry against a second cmux).
39
+ function runMaybeMissing(cmd, args, env = {}) {
40
+ try {
41
+ return { out: execFileSync(cmd, args, {
42
+ encoding: "utf8", timeout: TIMEOUT,
43
+ stdio: ["ignore", "pipe", "ignore"],
44
+ env: { ...process.env, ...env },
45
+ }), missing: false };
46
+ } catch (e) { return { out: "", missing: e && e.code === "ENOENT" }; }
47
+ }
48
+
49
+ // ~/.agent-bus/crew-windows.txt → [{project,kind,agent,handle}]. Row schema (crew.sh v3):
50
+ // PROJECT<TAB>KIND<TAB>AGENT<TAB>HANDLE, KIND ∈ win|attach|tmux|cmux|cmuxws. Legacy v2 rows are
51
+ // bare AGENT<TAB>WID (2 fields, no project) → {project:"",kind:"win"}. Anything else is skipped.
52
+ export function listCrewRows() {
53
+ try {
54
+ const f = join(busDir(), "crew-windows.txt");
55
+ if (!existsSync(f)) return [];
56
+ const out = [];
57
+ for (const line of readFileSync(f, "utf8").split("\n")) {
58
+ if (!line.trim()) continue;
59
+ const p = line.split("\t");
60
+ if (p.length >= 4) out.push({ project: p[0], kind: p[1], agent: p[2], handle: p[3] });
61
+ else if (p.length === 2 && p[0]) out.push({ project: "", kind: "win", agent: p[0], handle: p[1] });
62
+ }
63
+ return out;
64
+ } catch { return []; }
65
+ }
66
+
67
+ // Parse `ps -axo pid=,command=` once; shared by liveRunners() and devServers().
68
+ function psTable() {
69
+ const out = run("ps", ["-axo", "pid=,command="]);
70
+ const rows = [];
71
+ for (const line of out.split("\n")) {
72
+ const m = line.match(/^\s*(\d+)\s+(\S.*)$/);
73
+ if (m) rows.push({ pid: Number(m[1]), cmd: m[2] });
74
+ }
75
+ return rows;
76
+ }
77
+
78
+ // Live crew-runner processes → [{pid,agent,dir}]. Runner argv (crew.sh RUN_CMD) is
79
+ // `node …/crew-runner.mjs <agent> <dir>` — dir is the LAST argument, so the regex is anchored
80
+ // on end-of-string. project=null → all runners; project given → only runners whose dir resolves
81
+ // to that project. Resolution is the lib/project.mjs walk (git-root basename, else dir basename)
82
+ // compared with EXACT equality — a substring/prefix test would let …/proj match a runner in
83
+ // …/proj2 (the sibling-project reap bug).
84
+ export function liveRunners(project = null) {
85
+ try {
86
+ const out = [];
87
+ for (const { pid, cmd } of psTable()) {
88
+ const m = cmd.match(/crew-runner\.mjs\s+(\S+)\s+(\S+)\s*$/);
89
+ if (!m) continue;
90
+ const [, agent, dir] = m;
91
+ if (project != null) {
92
+ const name = basename(gitRoot(dir) || dir);
93
+ if (name !== project) continue;
94
+ }
95
+ out.push({ pid, agent, dir });
96
+ }
97
+ return out;
98
+ } catch { return []; }
99
+ }
100
+
101
+ // Open cmux workspaces → [{id,title}] via `cmux workspace list --json` (CMUX_QUIET=1). The socket
102
+ // may be off or cmux uninstalled — both are []. The CLI can print notice chatter before the JSON,
103
+ // so parse from the first [ or { (same trick as crew.sh).
104
+ export function cmuxWorkspaces() {
105
+ try {
106
+ let r = runMaybeMissing("cmux", ["workspace", "list", "--json"], { CMUX_QUIET: "1" });
107
+ if (r.missing) r = runMaybeMissing(CMUX_APP_BIN, ["workspace", "list", "--json"], { CMUX_QUIET: "1" });
108
+ if (!r.out) return [];
109
+ const i = r.out.search(/[\[{]/);
110
+ if (i < 0) return [];
111
+ const o = JSON.parse(r.out.slice(i));
112
+ const a = Array.isArray(o) ? o : (o.workspaces || []);
113
+ if (!Array.isArray(a)) return [];
114
+ return a
115
+ .map(w => ({ id: String(w?.id ?? ""), title: String(w?.custom_title ?? w?.name ?? w?.title ?? "") }))
116
+ .filter(w => w.id);
117
+ } catch { return []; }
118
+ }
119
+
120
+ // Dev-ish processes (next dev | vite | npm run dev | tail -f) whose CWD is under dir →
121
+ // [{pid,cmd}]. CWD comes from `lsof -a -p <pid> -d cwd -Fn`. "Under" is anchored: cwd === dir or
122
+ // cwd starts with dir + path separator — never a bare prefix (…/proj must not swallow …/proj2).
123
+ const DEV_RE = /(?:^|\/)next\s+dev(?:\s|$)|(?:^|\s|\/)vite(?:\s|$)|\bnpm\s+run\s+dev\b|\btail\s+-f\b/;
124
+ export function devServers(dir) {
125
+ try {
126
+ if (!dir) return [];
127
+ const root = String(dir).replace(/\/+$/, "");
128
+ if (!root) return [];
129
+ const out = [];
130
+ for (const { pid, cmd } of psTable()) {
131
+ if (!DEV_RE.test(cmd)) continue;
132
+ const lsof = run("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]);
133
+ const cwd = (lsof.match(/^n(.+)$/m) || [])[1]?.replace(/\/+$/, "") || "";
134
+ if (cwd && (cwd === root || cwd.startsWith(root + "/"))) out.push({ pid, cmd });
135
+ }
136
+ return out;
137
+ } catch { return []; }
138
+ }
139
+
140
+ // Compose the full inventory. project=null → machine-wide; devServers only when a directory for
141
+ // the project is actually known (a live runner's dir, or our own cwd when it resolves to the
142
+ // project) — else [].
143
+ export function inventory(project = null) {
144
+ try {
145
+ const rows = listCrewRows();
146
+ const runners = liveRunners(project);
147
+ const workspaces = cmuxWorkspaces();
148
+ let devs = [];
149
+ if (project != null) {
150
+ let dir = runners[0]?.dir || "";
151
+ if (!dir) {
152
+ const cwd = process.cwd();
153
+ if (basename(gitRoot(cwd) || cwd) === project) dir = cwd;
154
+ }
155
+ if (dir) devs = devServers(dir);
156
+ }
157
+ return { rows, runners, workspaces, devServers: devs };
158
+ } catch { return { rows: [], runners: [], workspaces: [], devServers: [] }; }
159
+ }
160
+
161
+ // The ONLY mutation: `bash <pkgroot>/bin/crew.sh prune` — drops crew-windows.txt rows whose
162
+ // handles are provably dead (never touches a process). RELAY_PROJECT is set when a project is
163
+ // given. Returns the command's stdout; "" on any failure.
164
+ export function cleanDead(project = null) {
165
+ return run("bash", [join(PKGROOT, "bin", "crew.sh"), "prune"],
166
+ project ? { RELAY_PROJECT: String(project) } : {});
167
+ }
@@ -7,9 +7,10 @@
7
7
  // env RELAY_URL → ~/.agent-bus/config.json {"url": "..."} → http://127.0.0.1:4477
8
8
  // Identity: env RELAY_SESSION → "<hostname>:<basename(cwd)>" (stable per project/machine)
9
9
  import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
10
- import { join, basename } from "node:path";
10
+ import { join, basename, dirname } from "node:path";
11
11
  import { homedir, hostname } from "node:os";
12
- import { execSync } from "node:child_process";
12
+ import { execSync, spawn } from "node:child_process";
13
+ import { fileURLToPath } from "node:url";
13
14
  import { resolveProject, hostId } from "../lib/project.mjs";
14
15
  import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
15
16
  import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
@@ -94,6 +95,11 @@ function sanitize(s) {
94
95
  return out;
95
96
  }
96
97
 
98
+ // Fail-silent wrapper for the optional #4214 resources detection lib (hooks/lib/resources.mjs).
99
+ // A throwing detector — or a half-landed lib whose export isn't a function yet — must never break
100
+ // session start; this returns dft on any error. The lib is itself fail-silent, this is defense-in-depth.
101
+ function safeInv(fn, dft = []) { try { const r = fn(); return r == null ? dft : r; } catch { return dft; } }
102
+
97
103
  // Session title for the picker / `claude --resume` / Claude mobile. Claude Code otherwise names a session
98
104
  // after its FIRST PROMPT (the `ai-title` transcript entry) — so several sessions started with the same
99
105
  // prompt (or sibling sessions in different projects) all look alike. We name it "<project> · <current work>"
@@ -156,6 +162,64 @@ try {
156
162
  additionalContext += `</trantor>\n`;
157
163
  }
158
164
 
165
+ // ── ADOPT live crews (intersession-ops S1+S2, contract #4215) ─────────────────
166
+ // Every boot inventories leftover crew resources via the #4214 detection lib and steers the
167
+ // session toward ADOPTING a live crew rather than `trantor up`-ing over it (replace-in-place
168
+ // kills the seats' accumulated context). Detection is sync + fail-silent; the dead-row cleanup
169
+ // runs in a detached, unref'd child so it NEVER blocks session start. The lib import is OPTIONAL
170
+ // on purpose — until kimi lands #4214 this whole block is a no-op rather than a hard import
171
+ // error that would break every session start. Added latency <300ms; everything wrapped; all
172
+ // injected text is sanitized; block kept ≤12 lines.
173
+ let res = null;
174
+ try { res = await import("./lib/resources.mjs"); } catch {}
175
+ if (res) {
176
+ try {
177
+ const __t0 = process.hrtime.bigint();
178
+ const __elapsedMs = () => Number(process.hrtime.bigint() - __t0) / 1e6;
179
+ const runners = safeInv(() => (typeof res.liveRunners === "function" ? res.liveRunners(project) : []), []);
180
+ const rows = safeInv(() => (typeof res.listCrewRows === "function" ? res.listCrewRows() : []), []);
181
+ const projRows = (Array.isArray(rows) ? rows : []).filter(r => r && r.project === project);
182
+ // Only touch the (relatively) costly devServers/lsof when we're actually emitting a block.
183
+ if ((Array.isArray(runners) && runners.length > 0) || projRows.length > 0) {
184
+ // devServers is "report only" and (per kimi's #4214 impl) costs ~180ms via per-match lsof.
185
+ // Include it only while the <300ms hard latency budget still has room; otherwise defer to the
186
+ // duty patrol (#4216), which inventories dev servers machine-wide. A solo session (no crew →
187
+ // liveRunners ~50ms) always gets the dev-server line; a live multi-seat crew usually defers.
188
+ let devSrv = [];
189
+ if (__elapsedMs() < 120) devSrv = safeInv(() => (typeof res.devServers === "function" ? res.devServers(projectDir) : []), []);
190
+ else process.stderr.write(`[trantor] devServers deferred to patrol (${__elapsedMs().toFixed(0)}ms elapsed, <300ms budget)\n`);
191
+ const seats = (Array.isArray(runners) ? runners : [])
192
+ .map(r => `${sanitize(r.agent || "?")}(${r.pid || "?"})`).filter(Boolean).join(", ");
193
+ const devs = (Array.isArray(devSrv) ? devSrv : [])
194
+ .map(d => `${sanitize(String(d.cmd || "dev").trim().split(/\s+/)[0] || "dev")}(${d.pid || "?"})`).join(", ");
195
+ const adopt = [];
196
+ if (Array.isArray(runners) && runners.length > 0) {
197
+ adopt.push(`A LIVE crew for "${sanitize(project)}" is already running (seats: ${seats}).`);
198
+ adopt.push(`ADOPT it: read \`relay_board\`, announce yourself to the seats over the bus, and continue their in-flight work.`);
199
+ adopt.push(`Do NOT run \`trantor up\` over healthy seats — replace-in-place kills their context.`);
200
+ if (devs) adopt.push(`Dev servers already up: ${devs} (report only — leave them running).`);
201
+ adopt.push(`Provably-dead tracking rows are being cleaned up in the background.`);
202
+ } else {
203
+ adopt.push(`No live crew for "${sanitize(project)}", but ${projRows.length} stale tracking row(s) exist from a prior session.`);
204
+ adopt.push(`Background cleanup is verifying them and dropping only the provably-dead (no live process AND no heartbeat AND no owning session).`);
205
+ if (devs) adopt.push(`Dev servers up: ${devs} (report only).`);
206
+ }
207
+ additionalContext += `<trantor-resources>\n${adopt.join("\n")}\n</trantor-resources>\n`;
208
+ process.stderr.write(`[trantor] crew inventory: ${runners.length} live, ${projRows.length} stale row(s) for ${project}\n`);
209
+ }
210
+ } catch {}
211
+ // Fire-and-forget dead-row cleanup — detached + unref'd + stdio ignored, NEVER awaited. We call
212
+ // cleanDead(project) by name through the #4214 lib; a missing/half-landed lib → child exits
213
+ // silently. This is the ONLY mutation and it drops dead tracking rows, nothing live.
214
+ try {
215
+ const modPath = join(dirname(fileURLToPath(import.meta.url)), "lib", "resources.mjs");
216
+ const kid = spawn(process.execPath, ["--input-type=module", "-e",
217
+ `import(${JSON.stringify(modPath)}).then(m=>{try{if(typeof m.cleanDead==="function")m.cleanDead(${JSON.stringify(project)})}catch{}}).catch(()=>{})`
218
+ ], { detached: true, stdio: "ignore", env: { ...process.env, RELAY_PROJECT: project } });
219
+ kid.unref();
220
+ } catch {}
221
+ }
222
+
159
223
  // CATCH-UP: a project is a DURABLE, continuous lane — not a session. Before doing
160
224
  // anything, this session reconciles with the living board: what's been built, what's
161
225
  // in flight, what's queued, plus the latest commits. So a fresh window resumes the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.61",
3
+ "version": "0.17.63",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,7 +11,7 @@
11
11
  "pg": "^8.22.0"
12
12
  },
13
13
  "scripts": {
14
- "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && bash test-crew.sh"
14
+ "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && bash test-crew.sh"
15
15
  },
16
16
  "description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
17
17
  "files": [