trantor 0.18.7 → 0.18.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/app.mjs +19 -4
- package/bin/crew-runner.mjs +40 -4
- package/bin/crew.sh +163 -6
- package/hub.mjs +32 -18
- package/lib/project.mjs +14 -0
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.9",
|
|
4
4
|
"description": "Trantor \u2014 the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/bin/app.mjs
CHANGED
|
@@ -77,9 +77,21 @@ await pipeline(Readable.fromWeb(dl.body), createWriteStream(dmg));
|
|
|
77
77
|
|
|
78
78
|
let mount = "";
|
|
79
79
|
try {
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
mount
|
|
80
|
+
// diskutil first: on macOS 26 the deprecated hdiutil shim IGNORES -nobrowse, so the mounted
|
|
81
|
+
// volume popped a Finder window mid-update and read as an install prompt (2026-08-27). Parse
|
|
82
|
+
// the mount point as everything after the last " at " — volume names can contain spaces.
|
|
83
|
+
try {
|
|
84
|
+
// real output (verified 2026-08-27): tab-separated, same shape as hdiutil —
|
|
85
|
+
// "/dev/disk12s1\tApple_HFS \t/Volumes/Trantor" — last tab field is the mount.
|
|
86
|
+
const out = sh("diskutil", ["image", "attach", "--mountOptions", "nobrowse", "--readOnly", dmg]);
|
|
87
|
+
const line = out.trim().split("\n").filter(l => l.includes("/Volumes/")).pop() || "";
|
|
88
|
+
mount = line.split("\t").pop().trim();
|
|
89
|
+
if (!mount.startsWith("/Volumes/")) throw new Error("no mount point in diskutil output");
|
|
90
|
+
} catch {
|
|
91
|
+
// older macOS: the original hdiutil path, tab-field parse (robust to spaces)
|
|
92
|
+
const out = sh("hdiutil", ["attach", "-nobrowse", "-readonly", dmg]);
|
|
93
|
+
mount = (out.trim().split("\n").pop() || "").split("\t").pop().trim();
|
|
94
|
+
}
|
|
83
95
|
const src = join(mount, "Trantor.app");
|
|
84
96
|
if (!mount.startsWith("/Volumes/") || !existsSync(src)) throw new Error(`unexpected DMG layout (mount: ${mount || "none"})`);
|
|
85
97
|
if (existsSync(APP)) { console.log(`replacing ${APP} (was ${have || "unknown"})`); rmSync(APP, { recursive: true, force: true }); }
|
|
@@ -91,6 +103,9 @@ try {
|
|
|
91
103
|
} catch (e) {
|
|
92
104
|
console.error(`install failed: ${e.message}`); process.exitCode = 1;
|
|
93
105
|
} finally {
|
|
94
|
-
if (mount)
|
|
106
|
+
if (mount) {
|
|
107
|
+
try { sh("diskutil", ["eject", mount]); }
|
|
108
|
+
catch { try { sh("hdiutil", ["detach", mount, "-quiet"]); } catch {} }
|
|
109
|
+
}
|
|
95
110
|
try { rmSync(dmg, { force: true }); } catch {}
|
|
96
111
|
}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// agent arrives it RESUMES the CLI session (native resume = full context kept) with that
|
|
10
10
|
// message as the prompt. The model just works and ends its turn; the runner does the rest.
|
|
11
11
|
import { execSync, spawnSync } from "node:child_process";
|
|
12
|
-
import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync } from "node:fs";
|
|
12
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync } from "node:fs";
|
|
13
13
|
import { join, basename } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { resolveProject, resolveHub, withEnvFiles } from "../lib/project.mjs";
|
|
@@ -24,6 +24,41 @@ const DIR = process.argv[3] || process.cwd();
|
|
|
24
24
|
// back to the git-repo-root basename — never a loose dir basename that could
|
|
25
25
|
// fork the host's "builtbetter.ai" into a separate "builtbetter" lane.
|
|
26
26
|
const PROJ = process.env.RELAY_PROJECT || resolveProject(DIR);
|
|
27
|
+
|
|
28
|
+
function safePathSegment(s) {
|
|
29
|
+
return String(s).replace(/\.{2,}/g, "_").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function gitOut(args, cwd = DIR) {
|
|
33
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 8000 });
|
|
34
|
+
return r.status === 0 ? String(r.stdout || "").trim() : "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ensureSeatWorktree(sourceDir) {
|
|
38
|
+
if (process.env.TRANTOR_NO_WORKTREE === "1") return sourceDir;
|
|
39
|
+
const root = gitOut(["-C", sourceDir, "rev-parse", "--show-toplevel"], sourceDir);
|
|
40
|
+
if (!root) return sourceDir;
|
|
41
|
+
|
|
42
|
+
spawnSync("git", ["-C", root, "worktree", "prune"], { stdio: "ignore", timeout: 8000 });
|
|
43
|
+
const seatDir = join(homedir(), ".agent-bus", "worktrees", safePathSegment(PROJ), safePathSegment(AGENT));
|
|
44
|
+
const branch = `seat/${AGENT}`;
|
|
45
|
+
if (existsSync(seatDir)) {
|
|
46
|
+
const ok = gitOut(["-C", seatDir, "rev-parse", "--is-inside-work-tree"], seatDir) === "true";
|
|
47
|
+
if (ok) return seatDir;
|
|
48
|
+
console.log(`\x1b[33m[runner]\x1b[0m worktree path exists but is not a git worktree: ${seatDir} — using ${sourceDir}`);
|
|
49
|
+
return sourceDir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try { mkdirSync(join(homedir(), ".agent-bus", "worktrees", safePathSegment(PROJ)), { recursive: true }); } catch {}
|
|
53
|
+
const r = spawnSync("git", ["-C", root, "worktree", "add", "-B", branch, seatDir, "HEAD"], {
|
|
54
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000,
|
|
55
|
+
});
|
|
56
|
+
if (r.status === 0) return seatDir;
|
|
57
|
+
console.log(`\x1b[33m[runner]\x1b[0m could not create ${branch} worktree — using ${sourceDir}`);
|
|
58
|
+
return sourceDir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const TURN_DIR = ensureSeatWorktree(DIR);
|
|
27
62
|
// RUNNER_SESSION override: an orchestrator seat (bin/orchestrate.mjs) runs the same CLI as a crew
|
|
28
63
|
// seat but must live on the bus under its own name (claude-orch:proj), or it would collide with a
|
|
29
64
|
// plain claude crew seat on the same project.
|
|
@@ -53,7 +88,6 @@ process.on("uncaughtException", (e) => { console.log(`\x1b[31m[runner] UNCAUGHT:
|
|
|
53
88
|
process.on("unhandledRejection", (e) => { console.log(`\x1b[31m[runner] UNHANDLED REJECTION: ${e?.stack || e}\x1b[0m`); });
|
|
54
89
|
const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${s}`);
|
|
55
90
|
const LOGDIR = join(homedir(), ".agent-bus", "logs");
|
|
56
|
-
import { mkdirSync } from "node:fs";
|
|
57
91
|
try { mkdirSync(LOGDIR, { recursive: true }); } catch {}
|
|
58
92
|
let TURN = 0;
|
|
59
93
|
const telemetry = (rec) => { try { appendFileSync(join(LOGDIR, `${AGENT}-${PROJ}.jsonl`), JSON.stringify(rec) + "\n"); } catch {} };
|
|
@@ -278,7 +312,9 @@ async function notifyAssigners(pairs, text) {
|
|
|
278
312
|
seen.add(f);
|
|
279
313
|
// `re` threads this outcome to the exact contract it answers, so the sender's ledger closes the
|
|
280
314
|
// right one instead of guessing from timing.
|
|
281
|
-
|
|
315
|
+
const payload = { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ };
|
|
316
|
+
if (id) payload.re = id;
|
|
317
|
+
await api("/send", payload).catch(() => {});
|
|
282
318
|
}
|
|
283
319
|
if (seen.size) log(`reported outcome to ${[...seen].join(", ")}`);
|
|
284
320
|
}
|
|
@@ -325,7 +361,7 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
325
361
|
// substitution) so bash waits for tee to flush before we read the file back.
|
|
326
362
|
const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | tee -a ${ERRF}`;
|
|
327
363
|
const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(tee -a ${ERRF} >&2)`], {
|
|
328
|
-
cwd:
|
|
364
|
+
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
329
365
|
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
|
|
330
366
|
// A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
|
|
331
367
|
//
|
package/bin/crew.sh
CHANGED
|
@@ -46,11 +46,19 @@ HAVE_TMUX=0; command -v tmux >/dev/null 2>&1 && HAVE_TMUX=1
|
|
|
46
46
|
# Events) — NOT the control socket — so it needs NO socket password (the socket denies external processes
|
|
47
47
|
# by default; AppleScript bypasses that, exactly like we already script Terminal.app).
|
|
48
48
|
HAVE_CMUX=0; [ -d "/Applications/cmux.app" ] && HAVE_CMUX=1
|
|
49
|
-
#
|
|
49
|
+
# herdr (https://herdr.dev — a server-held terminal runtime: panes live in its background server, so a
|
|
50
|
+
# crew survives the launcher exiting) is OPT-IN ONLY, never auto-detected — a machine with herdr
|
|
51
|
+
# installed keeps its default cmux→tmux→terminal dispatch untouched. CREW_MUX=herdr is the explicit
|
|
52
|
+
# switch; opting in without the binary is a HARD ERROR, because a silent fallback would defeat the ask.
|
|
53
|
+
HAVE_HERDR=0
|
|
54
|
+
# explicit override (user preference or tests): CREW_MUX=cmux|tmux|terminal|herdr forces the grouping UI.
|
|
50
55
|
case "${CREW_MUX:-}" in
|
|
51
56
|
cmux) HAVE_CMUX=1; HAVE_TMUX=0 ;;
|
|
52
57
|
tmux) HAVE_CMUX=0; HAVE_TMUX=1 ;;
|
|
53
58
|
terminal) HAVE_CMUX=0; HAVE_TMUX=0 ;;
|
|
59
|
+
herdr) HAVE_CMUX=0; HAVE_TMUX=0
|
|
60
|
+
command -v herdr >/dev/null 2>&1 || { echo "CREW_MUX=herdr but herdr is not installed — user-local (no sudo): curl -fsSL https://herdr.dev/install.sh | sh — see https://herdr.dev"; exit 1; }
|
|
61
|
+
HAVE_HERDR=1 ;;
|
|
54
62
|
esac
|
|
55
63
|
SEATDIR="$HOME/.agent-bus/seats"; mkdir -p "$SEATDIR"
|
|
56
64
|
DRY="${CREW_DRY_RUN:-0}"
|
|
@@ -136,6 +144,137 @@ end tell
|
|
|
136
144
|
OSA
|
|
137
145
|
}
|
|
138
146
|
|
|
147
|
+
# ── herdr helpers (active ONLY under the explicit CREW_MUX=herdr opt-in) ────────────────────────────
|
|
148
|
+
# herdr's creation commands print JSON ids — capture them, never predict (herdr.dev/docs). All parsing
|
|
149
|
+
# tolerates a bare array or a .result wrapper, and a failed/empty parse yields "" (the caller records
|
|
150
|
+
# the empty handle and crew-verify flags the dead seat on the bus — the spawn is not the truth).
|
|
151
|
+
_herdr() { herdr "$@"; }
|
|
152
|
+
_herdr_ws_create() { # $1=cwd $2=label → "workspace_id<TAB>root_pane_id"
|
|
153
|
+
_herdr workspace create --cwd "$1" --label "$2" --no-focus 2>/dev/null | node -e '
|
|
154
|
+
let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const r=(JSON.parse(d.slice(d.search(/[\[{]/))).result)||{};
|
|
155
|
+
process.stdout.write((((r.workspace||{}).workspace_id)||"")+"\t"+(((r.root_pane||{}).pane_id)||""))}catch(e){}})'
|
|
156
|
+
}
|
|
157
|
+
_herdr_split() { # $1=pane id ("" = UI-focused pane) $2=right|down → new pane id
|
|
158
|
+
local a=(pane split); [ -n "$1" ] && a+=("$1"); a+=(--direction "$2" --no-focus)
|
|
159
|
+
_herdr "${a[@]}" 2>/dev/null | node -e '
|
|
160
|
+
let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const r=(JSON.parse(d.slice(d.search(/[\[{]/))).result)||{};
|
|
161
|
+
process.stdout.write(((r.pane||{}).pane_id)||"")}catch(e){}})'
|
|
162
|
+
}
|
|
163
|
+
_herdr_ws_live() { # workspace list → "ids<TABnewline>names" (names \x01-wrapped+joined, like cmux)
|
|
164
|
+
_herdr workspace list 2>/dev/null | node -e '
|
|
165
|
+
let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));
|
|
166
|
+
const a=Array.isArray(o)?o:(o.workspaces||((o.result||{}).workspaces)||[]);
|
|
167
|
+
console.log(a.map(x=>x.workspace_id||x.id||"").filter(Boolean).join(" "));
|
|
168
|
+
console.log("\u0001"+a.map(x=>x.label||x.name||x.custom_title||"").filter(Boolean).join("\u0001")+"\u0001")}catch(e){}})'
|
|
169
|
+
}
|
|
170
|
+
# Teardown works regardless of CREW_MUX: you must never need to remember the flag to tear a crew down.
|
|
171
|
+
_herdr_close_ws() { [ "$DRY" = "1" ] && { echo "[dry] herdr workspace close $1"; return 0; }; _herdr workspace close "$1" >/dev/null 2>&1; }
|
|
172
|
+
_herdr_close_pane() { [ "$DRY" = "1" ] && { echo "[dry] herdr pane close $1"; return 0; }; _herdr pane close "$1" >/dev/null 2>&1; }
|
|
173
|
+
|
|
174
|
+
# ── herdr spawn: ONE workspace `trantor:$PROJ`, one named pane per seat ─────────────────────────────
|
|
175
|
+
# Topology = workspace create (its root pane runs seat 1) + pane split for the rest, same ceil(√N) grid
|
|
176
|
+
# doctrine as cmux; pane rename labels each seat; pane run submits the seat command (bracketed-paste
|
|
177
|
+
# safe, sends Enter). Same replace-never-stack doctrine: REUSE the newest tracked workspace for this
|
|
178
|
+
# project, close older stacked ones, adopt one untracked live trantor:$PROJ workspace.
|
|
179
|
+
spawn_herdr() { # $@ = specs
|
|
180
|
+
local REUSE_WS="" line w
|
|
181
|
+
local stale_ws=()
|
|
182
|
+
if [ -f "$STATE" ]; then
|
|
183
|
+
while IFS= read -r line; do
|
|
184
|
+
[ -n "$line" ] || continue
|
|
185
|
+
_parse_row "$line"
|
|
186
|
+
{ [ "$RP" = "$PROJ" ] && [ "$RK" = "herdrws" ]; } || continue
|
|
187
|
+
[ -n "$REUSE_WS" ] && stale_ws+=("$REUSE_WS")
|
|
188
|
+
REUSE_WS="$RH"
|
|
189
|
+
done < "$STATE"
|
|
190
|
+
fi
|
|
191
|
+
if [ "${#stale_ws[@]}" -gt 0 ]; then
|
|
192
|
+
for w in "${stale_ws[@]}"; do
|
|
193
|
+
echo " → closing stale stacked crew workspace for $PROJ ($w)"
|
|
194
|
+
_herdr_close_ws "$w"; _state_drop "$PROJ" "herdrws" "" "$w"
|
|
195
|
+
done
|
|
196
|
+
prune_dead_state # the closed workspaces' seat rows just died with them
|
|
197
|
+
fi
|
|
198
|
+
# Untracked strays: LIVE workspaces labeled trantor:$proj that STATE doesn't know. Adopt one as the
|
|
199
|
+
# reuse target if we have none; close the rest. (Skipped in DRY: read-only drills seed STATE instead.)
|
|
200
|
+
if [ "$DRY" != "1" ]; then
|
|
201
|
+
local named nid
|
|
202
|
+
named="$(_herdr workspace list 2>/dev/null | WSNAME="trantor:$PROJ" 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||((o.result||{}).workspaces)||[]);console.log(a.filter(x=>x.label===process.env.WSNAME||x.name===process.env.WSNAME||x.custom_title===process.env.WSNAME).map(x=>x.workspace_id||x.id||"").filter(Boolean).join(" "))}catch(e){}})')"
|
|
203
|
+
for nid in $named; do
|
|
204
|
+
[ "$nid" = "$REUSE_WS" ] && continue
|
|
205
|
+
[ -f "$STATE" ] && grep -qF "$nid" "$STATE" && continue # tracked → handled above
|
|
206
|
+
if [ -z "$REUSE_WS" ]; then
|
|
207
|
+
echo " → adopting existing untracked crew workspace for $PROJ ($nid)"
|
|
208
|
+
REUSE_WS="$nid"; record_state "$PROJ" "herdrws" "__ws__" "$nid"
|
|
209
|
+
else
|
|
210
|
+
echo " → closing stray crew workspace for $PROJ ($nid)"
|
|
211
|
+
_herdr_close_ws "$nid"
|
|
212
|
+
fi
|
|
213
|
+
done
|
|
214
|
+
fi
|
|
215
|
+
# Grid tiling (identical math to cmux): row 0 splits RIGHT off the previous column, later rows split
|
|
216
|
+
# DOWN from the pane directly above. In REUSE mode a replaced seat splits off its OWN old pane —
|
|
217
|
+
# keeping its spot — and added seats split right off the previous new pane.
|
|
218
|
+
local N=$# COLS=1; while [ $(( COLS * COLS )) -lt "$N" ]; do COLS=$(( COLS + 1 )); done
|
|
219
|
+
local SPEC wsid="" surf i=0
|
|
220
|
+
local surfs=()
|
|
221
|
+
[ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
|
|
222
|
+
for SPEC in "$@"; do
|
|
223
|
+
resolve_spec "$SPEC"
|
|
224
|
+
local cmd; cmd="$(RUN_CMD)"
|
|
225
|
+
if [ -n "$REUSE_WS" ]; then
|
|
226
|
+
# replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
|
|
227
|
+
# then close the old pane — split-first so the workspace never dips to zero panes mid-swap.
|
|
228
|
+
local OLD_SURF=""
|
|
229
|
+
if [ -f "$STATE" ]; then
|
|
230
|
+
while IFS= read -r line; do
|
|
231
|
+
[ -n "$line" ] || continue
|
|
232
|
+
_parse_row "$line"
|
|
233
|
+
[ "$RP" = "$PROJ" ] && [ "$RK" = "herdr" ] && [ "$RA" = "$AGENT" ] && OLD_SURF="$RH"
|
|
234
|
+
done < "$STATE"
|
|
235
|
+
fi
|
|
236
|
+
local t=""
|
|
237
|
+
[ -n "$OLD_SURF" ] && t="$OLD_SURF"
|
|
238
|
+
{ [ -z "$t" ] && [ "$i" -gt 0 ]; } && t="${surfs[$(( i - 1 ))]}"
|
|
239
|
+
if [ "$DRY" = "1" ]; then
|
|
240
|
+
echo "[dry] herdr: reuse workspace $wsid — pane split for $AGENT${OLD_SURF:+ (replacing $OLD_SURF)}"
|
|
241
|
+
surf="%DRYT$i"
|
|
242
|
+
[ -n "$OLD_SURF" ] && _herdr_close_pane "$OLD_SURF"
|
|
243
|
+
else
|
|
244
|
+
surf="$(_herdr_split "$t" right)"
|
|
245
|
+
[ -n "$surf" ] && { _herdr pane rename "$surf" "$AGENT · $PROJ" >/dev/null 2>&1; _herdr pane run "$surf" "$cmd" >/dev/null 2>&1; }
|
|
246
|
+
if [ -n "$OLD_SURF" ]; then _herdr_close_pane "$OLD_SURF"; _state_drop "$PROJ" "herdr" "$AGENT" ""; fi
|
|
247
|
+
fi
|
|
248
|
+
elif [ "$i" = "0" ]; then
|
|
249
|
+
if [ "$DRY" = "1" ]; then
|
|
250
|
+
echo "[dry] herdr: workspace create (cwd $DIR) --label 'trantor:$PROJ' → root pane + run '$cmd'"
|
|
251
|
+
wsid="%DRYWS"; surf="%DRYT0"
|
|
252
|
+
else
|
|
253
|
+
local pair; pair="$(_herdr_ws_create "$DIR" "trantor:$PROJ")"
|
|
254
|
+
wsid="${pair%%$'\t'*}"; surf="${pair##*$'\t'}"
|
|
255
|
+
[ -n "$surf" ] && { _herdr pane rename "$surf" "$AGENT · $PROJ" >/dev/null 2>&1; _herdr pane run "$surf" "$cmd" >/dev/null 2>&1; }
|
|
256
|
+
fi
|
|
257
|
+
record_state "$PROJ" "herdrws" "__ws__" "$wsid"
|
|
258
|
+
else
|
|
259
|
+
local dir target
|
|
260
|
+
if [ $(( i / COLS )) = "0" ]; then dir="right"; target="${surfs[$(( i - 1 ))]}"
|
|
261
|
+
else dir="down"; target="${surfs[$(( i - COLS ))]}"; fi
|
|
262
|
+
if [ "$DRY" = "1" ]; then
|
|
263
|
+
echo "[dry] herdr: pane split ${target:-<focused>} --direction $dir + run '$cmd'"
|
|
264
|
+
surf="%DRYT$i"
|
|
265
|
+
else
|
|
266
|
+
surf="$(_herdr_split "$target" "$dir")"
|
|
267
|
+
[ -n "$surf" ] && { _herdr pane rename "$surf" "$AGENT · $PROJ" >/dev/null 2>&1; _herdr pane run "$surf" "$cmd" >/dev/null 2>&1; }
|
|
268
|
+
fi
|
|
269
|
+
fi
|
|
270
|
+
surfs+=("$surf")
|
|
271
|
+
record_state "$PROJ" "herdr" "$AGENT" "$surf"
|
|
272
|
+
echo " → $AGENT seat in herdr workspace ($PROJ)"
|
|
273
|
+
i=$(( i + 1 ))
|
|
274
|
+
done
|
|
275
|
+
echo "— crew grouped in herdr: ONE workspace for $PROJ, seats as named panes in its server. Teardown (this project only): trantor down —"
|
|
276
|
+
}
|
|
277
|
+
|
|
139
278
|
# surgical STATE row removal: drop every row matching PROJECT+KIND (+AGENT/+HANDLE when given).
|
|
140
279
|
# Empty $3/$4 = wildcard. Callers beware: uses _parse_row, which clobbers the RP/RK/RA/RH globals.
|
|
141
280
|
_state_drop() { # $1=project $2=kind $3=agent(''=any) $4=handle(''=any)
|
|
@@ -202,7 +341,7 @@ down() {
|
|
|
202
341
|
[ "$match" = "1" ] || continue
|
|
203
342
|
fi
|
|
204
343
|
scoped+=("$RP|$RK|$RA|$RH")
|
|
205
|
-
case "$RK" in attach|cmuxws) continue ;; esac # infra rows (attach
|
|
344
|
+
case "$RK" in attach|cmuxws|herdrws) continue ;; esac # infra rows (attach/cmux/herdr workspace) — not seats
|
|
206
345
|
killlist="$killlist • ${RP:-<legacy>} · $RA ($RK)"$'\n'
|
|
207
346
|
done < "$STATE"
|
|
208
347
|
|
|
@@ -220,6 +359,8 @@ down() {
|
|
|
220
359
|
case "$K2" in
|
|
221
360
|
cmuxws) [ "${#AGENTS[@]}" -gt 0 ] || _cmux_close_tab "$H2" ;; # whole workspace (no specific seats)
|
|
222
361
|
cmux) [ "${#AGENTS[@]}" -gt 0 ] && _cmux_close_term "$H2" ;; # a single seat's pane
|
|
362
|
+
herdrws) [ "${#AGENTS[@]}" -gt 0 ] || _herdr_close_ws "$H2" ;; # whole workspace (no specific seats)
|
|
363
|
+
herdr) [ "${#AGENTS[@]}" -gt 0 ] && _herdr_close_pane "$H2" ;;# a single seat's pane
|
|
223
364
|
tmux)
|
|
224
365
|
if [ "${#AGENTS[@]}" -gt 0 ]; then run "tmux kill-pane -t '$H2' 2>/dev/null" # per-seat
|
|
225
366
|
else
|
|
@@ -228,7 +369,7 @@ down() {
|
|
|
228
369
|
fi ;;
|
|
229
370
|
win|attach) { [ "${#AGENTS[@]}" -gt 0 ] && [ "$K2" = "attach" ]; } && continue; _kill_win "$H2" ;;
|
|
230
371
|
esac
|
|
231
|
-
case "$K2" in cmuxws|attach) : ;; *) _kill_seat_procs "$P2" "$A2" ;; esac
|
|
372
|
+
case "$K2" in cmuxws|attach|herdrws) : ;; *) _kill_seat_procs "$P2" "$A2" ;; esac
|
|
232
373
|
done
|
|
233
374
|
|
|
234
375
|
# rewrite STATE minus the rows we tore down (leaves OTHER projects' rows intact)
|
|
@@ -268,6 +409,15 @@ prune_dead_state() {
|
|
|
268
409
|
CLIVE="$(printf '%s' "$pair" | sed -n 1p)"
|
|
269
410
|
CLIVE_NAMES="$(printf '%s' "$pair" | sed -n 2p)"
|
|
270
411
|
fi
|
|
412
|
+
# herdr liveness is queried ONLY under the explicit opt-in (CREW_MUX=herdr): a default run never
|
|
413
|
+
# invokes herdr at all. Same keep-when-unprovable doctrine as cmux — no answer ⇒ rows are KEPT.
|
|
414
|
+
local HLIVE="" HLIVE_NAMES=""
|
|
415
|
+
if [ "$HAVE_HERDR" = "1" ]; then
|
|
416
|
+
local hpair
|
|
417
|
+
hpair="$(_herdr_ws_live)"
|
|
418
|
+
HLIVE="$(printf '%s' "$hpair" | sed -n 1p)"
|
|
419
|
+
HLIVE_NAMES="$(printf '%s' "$hpair" | sed -n 2p)"
|
|
420
|
+
fi
|
|
271
421
|
local tmp="$STATE.tmp" line alive
|
|
272
422
|
: > "$tmp"
|
|
273
423
|
while IFS= read -r line; do
|
|
@@ -283,6 +433,12 @@ prune_dead_state() {
|
|
|
283
433
|
elif [ "$RK" = "cmux" ]; then
|
|
284
434
|
# seat row lives exactly as long as its project still has a live crew workspace
|
|
285
435
|
if [ -n "$CLIVE" ]; then case "$CLIVE_NAMES" in *$'\x01'"trantor:$RP"$'\x01'*) : ;; *) alive=0 ;; esac; fi
|
|
436
|
+
elif [ "$RK" = "herdrws" ]; then
|
|
437
|
+
if [ -n "$HLIVE" ]; then case " $HLIVE " in *" $RH "*) : ;; *) alive=0 ;; esac; fi
|
|
438
|
+
elif [ "$RK" = "herdr" ]; then
|
|
439
|
+
# seat row lives exactly as long as its project still has a live crew workspace — validated at
|
|
440
|
+
# WORKSPACE granularity, never per pane (the cmux 0.17.61 lesson generalized)
|
|
441
|
+
if [ -n "$HLIVE" ]; then case "$HLIVE_NAMES" in *$'\x01'"trantor:$RP"$'\x01'*) : ;; *) alive=0 ;; esac; fi
|
|
286
442
|
fi
|
|
287
443
|
[ "$alive" = "1" ] && printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
|
|
288
444
|
done < "$STATE"
|
|
@@ -690,8 +846,9 @@ OSA
|
|
|
690
846
|
echo "— crew grouped in cmux (AppleScript): ONE workspace tab for $PROJ, seats tiled. Teardown: trantor down —"
|
|
691
847
|
}
|
|
692
848
|
|
|
693
|
-
spawn_crew() { # dispatch:
|
|
694
|
-
if [ "$
|
|
849
|
+
spawn_crew() { # dispatch: herdr (explicit opt-in) → cmux → tmux → Terminal grid
|
|
850
|
+
if [ "$HAVE_HERDR" = "1" ]; then spawn_herdr "$@"
|
|
851
|
+
elif [ "$HAVE_CMUX" = "1" ]; then spawn_cmux "$@"
|
|
695
852
|
elif [ "$HAVE_TMUX" = "1" ]; then spawn_tmux "$@"
|
|
696
853
|
else
|
|
697
854
|
echo "— no cmux/tmux → per-agent Terminal windows. For ONE grouped, named window per crew (and"
|
|
@@ -729,7 +886,7 @@ fi
|
|
|
729
886
|
|
|
730
887
|
spec_for_agent() { local want="$1"; shift; local s; for s in "$@"; do [ "${s%%:*}" = "$want" ] && { printf '%s' "$s"; return; }; done; printf '%s' "$want"; }
|
|
731
888
|
|
|
732
|
-
CREW_UI="Terminal windows"; [ "$HAVE_TMUX" = "1" ] && CREW_UI="tmux"; [ "$HAVE_CMUX" = "1" ] && CREW_UI="cmux"
|
|
889
|
+
CREW_UI="Terminal windows"; [ "$HAVE_TMUX" = "1" ] && CREW_UI="tmux"; [ "$HAVE_CMUX" = "1" ] && CREW_UI="cmux"; [ "$HAVE_HERDR" = "1" ] && CREW_UI="herdr"
|
|
733
890
|
echo "— bringing up crew for $PROJ ($CREW_UI) —"
|
|
734
891
|
SPAWN_EPOCH=$(epoch_ms)
|
|
735
892
|
spawn_crew "$@"
|
package/hub.mjs
CHANGED
|
@@ -343,32 +343,46 @@ function overseerTick() {
|
|
|
343
343
|
overseerLastTick = t;
|
|
344
344
|
const pol = overseerPolicy();
|
|
345
345
|
const seen = new Set();
|
|
346
|
+
// Hand each party the others' session ids at the moment coordination is warranted. Telling two
|
|
347
|
+
// sessions to "coordinate over the bus" is useless if neither knows the other's id, and the
|
|
348
|
+
// warning alone went only to the duty seat and the log — so coordination needed a human to carry
|
|
349
|
+
// the ids across. Shared by the episode-start branch (all parties) and the standing branch
|
|
350
|
+
// (newcomers only): existing members never re-hear it, so a standing condition must not re-wake
|
|
351
|
+
// every party every tick.
|
|
352
|
+
const intro = (c, me, others) => {
|
|
353
|
+
const rest = others.filter(p => p !== me);
|
|
354
|
+
if (rest.length === 0) return;
|
|
355
|
+
hubSend(me,
|
|
356
|
+
`🤝 OVERSEER ${c.kind}: you and ${rest.join(", ")} are working on overlapping ground${c.files?.length ? ` (${c.files.slice(0, 3).join(", ")})` : ""}. ${c.detail || ""} Coordinate directly — relay_send to ${rest[0]} — and split the work between you. No human needs to relay this.`,
|
|
357
|
+
c.project);
|
|
358
|
+
};
|
|
346
359
|
for (const c of collisions) {
|
|
347
|
-
|
|
360
|
+
// Episode identity is the CONDITION (project+kind+files), never the session list (#5350):
|
|
361
|
+
// membership is volatile — a third seat bouncing in and out of a standing collision minted a
|
|
362
|
+
// fresh key, so a fresh episode, so a fresh warn (+ duty wake + party intros) per permutation.
|
|
363
|
+
// Sessions are participants, not identity; current membership still rides every warn payload.
|
|
364
|
+
const key = `${c.project} ${c.kind} ${(c.files || []).join(",")}`;
|
|
348
365
|
c.key = key;
|
|
349
366
|
seen.add(key);
|
|
367
|
+
const parties = [...new Set(c.sessions || [])].filter(s => s && s !== DUTY_SESSION);
|
|
350
368
|
const standing = overseerActive.get(key);
|
|
351
|
-
if (standing) {
|
|
352
|
-
|
|
369
|
+
if (standing) {
|
|
370
|
+
// The episode HOLDS — no new warn. But a NEWCOMER to a standing collision still needs the
|
|
371
|
+
// intro: it was not present when the episode started, so it never learned the others' ids.
|
|
372
|
+
// Diff the current membership against the set the episode has already introduced, hand the
|
|
373
|
+
// intro only to newly arrived sessions, and remember them so they are not re-introduced.
|
|
374
|
+
standing.lastTick = t;
|
|
375
|
+
c.since = standing.since;
|
|
376
|
+
for (const me of parties) if (!standing.sessions.has(me)) intro(c, me, parties);
|
|
377
|
+
for (const me of parties) standing.sessions.add(me);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
overseerActive.set(key, { since: t, lastTick: t, sessions: new Set(parties) });
|
|
353
381
|
c.since = t;
|
|
354
382
|
appendEvent("overseer.warn", c.project, "overseer",
|
|
355
383
|
{ kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
|
|
356
384
|
if (DUTY_SESSION) hubSend(DUTY_SESSION, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
|
|
357
|
-
|
|
358
|
-
// useless if neither knows the other's session id, and until now the warning went only to the
|
|
359
|
-
// duty seat and the log — so coordination needed a human to carry the ids across. Hand each
|
|
360
|
-
// party the others' ids at the moment coordination is warranted. This sits inside the
|
|
361
|
-
// episode-start branch, so it fires ONCE per episode, not once per tick: a standing condition
|
|
362
|
-
// must not re-wake two sessions every 30 seconds.
|
|
363
|
-
const parties = [...new Set(c.sessions || [])].filter(s => s && s !== DUTY_SESSION);
|
|
364
|
-
if (parties.length > 1) {
|
|
365
|
-
for (const me of parties) {
|
|
366
|
-
const others = parties.filter(p => p !== me);
|
|
367
|
-
hubSend(me,
|
|
368
|
-
`🤝 OVERSEER ${c.kind}: you and ${others.join(", ")} are working on overlapping ground${c.files?.length ? ` (${c.files.slice(0, 3).join(", ")})` : ""}. ${c.detail || ""} Coordinate directly — relay_send to ${others[0]} — and split the work between you. No human needs to relay this.`,
|
|
369
|
-
c.project);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
385
|
+
if (parties.length > 1) for (const me of parties) intro(c, me, parties);
|
|
372
386
|
const level = _overseer.levelFor ? _overseer.levelFor(c.project, pol.autonomy) : 1;
|
|
373
387
|
if (level >= 3 && c.kind === "file-conflict") {
|
|
374
388
|
const g = { id: ++state.verifyGateSeq, project: c.project, status: "open", ts: now(),
|
package/lib/project.mjs
CHANGED
|
@@ -21,6 +21,20 @@ export function gitRoot(dir) {
|
|
|
21
21
|
export function resolveProject(cwd = process.cwd()) {
|
|
22
22
|
if (process.env.RELAY_PROJECT) return process.env.RELAY_PROJECT.slice(0, 80);
|
|
23
23
|
const root = gitRoot(cwd);
|
|
24
|
+
// A LINKED WORKTREE must resolve to its MAIN repo's name, not its own directory name. Seat
|
|
25
|
+
// worktrees live at ~/.agent-bus/worktrees/<project>/<agent>, so the old basename rule named the
|
|
26
|
+
// project after the AGENT — codex's seat registered as codex:codex the first time a worktree crew
|
|
27
|
+
// came up (2026-08-27), because codex spawns its MCP with a sanitized env, so the RELAY_PROJECT
|
|
28
|
+
// guard above never arrives there. git-common-dir points at the main repo's .git from any
|
|
29
|
+
// worktree; in the main repo it equals its own .git, so this is a no-op for normal checkouts.
|
|
30
|
+
if (root) {
|
|
31
|
+
try {
|
|
32
|
+
const common = execSync("git rev-parse --path-format=absolute --git-common-dir", {
|
|
33
|
+
cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000,
|
|
34
|
+
}).trim();
|
|
35
|
+
if (common.endsWith("/.git")) return basename(dirname(common)).slice(0, 80);
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
24
38
|
return basename(root || cwd).slice(0, 80);
|
|
25
39
|
}
|
|
26
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"zod": "^4.4.3"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.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-cardlog.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.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-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
|
|
14
|
+
"test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.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-cardlog.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.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-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
|
|
15
15
|
},
|
|
16
16
|
"description": "The hub-world for AI agent crews \u2014 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": [
|