trantor 0.18.6 → 0.18.8
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/crew-runner.mjs +52 -5
- package/bin/crew.sh +163 -6
- package/bin/duty.mjs +75 -4
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.8",
|
|
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/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,8 +361,19 @@ 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:
|
|
329
|
-
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ
|
|
364
|
+
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
365
|
+
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
|
|
366
|
+
// A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
|
|
367
|
+
//
|
|
368
|
+
// The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
|
|
369
|
+
// handoff and opens a fresh window to carry on. A seat has no use for that — the runner is its
|
|
370
|
+
// lifecycle manager and wakes it per event — so the spawn just leaks an unmanaged interactive
|
|
371
|
+
// session into a window nobody asked for.
|
|
372
|
+
//
|
|
373
|
+
// Observed on the duty seat: handoff records at 17:24 and 18:59 on 2026-08-24, and two stray
|
|
374
|
+
// `claude` processes in ~/.agent-bus/trantor-duty started at 17:24:57 and 18:59:50, still
|
|
375
|
+
// sitting there days later. To the operator that reads as "why are there two duty agents".
|
|
376
|
+
TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
|
|
330
377
|
maxBuffer: 16 * 1024 * 1024,
|
|
331
378
|
});
|
|
332
379
|
try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
|
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/bin/duty.mjs
CHANGED
|
@@ -116,6 +116,47 @@ function alivePid() {
|
|
|
116
116
|
return 0;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// The cmux workspace this seat owns. One name, so `up` can find and replace its predecessor and
|
|
120
|
+
// `down` can take the surface away with the process.
|
|
121
|
+
const CMUX_WS_NAME = "trantor-duty";
|
|
122
|
+
|
|
123
|
+
// Surface override, same variable and same values bin/crew.sh already uses for crew seats:
|
|
124
|
+
// CREW_MUX=terminal force a Terminal window (what the window-content drills assert on)
|
|
125
|
+
// CREW_MUX=cmux require cmux
|
|
126
|
+
// unset / anything else = auto: cmux when it answers, Terminal otherwise.
|
|
127
|
+
const SURFACE = String(process.env.CREW_MUX || "auto").toLowerCase();
|
|
128
|
+
|
|
129
|
+
function cmuxBinary() {
|
|
130
|
+
if (SURFACE === "terminal") return "";
|
|
131
|
+
for (const c of ["cmux", "/Applications/cmux.app/Contents/Resources/bin/cmux"]) {
|
|
132
|
+
try { execSync(`${c} ping`, { stdio: "ignore", timeout: 3000 }); return c; } catch {}
|
|
133
|
+
}
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Close every workspace this seat owns. No-op when cmux is absent or its socket is off. */
|
|
138
|
+
function closeDutyWorkspace() {
|
|
139
|
+
const bin = cmuxBinary();
|
|
140
|
+
if (!bin) return 0;
|
|
141
|
+
let closed = 0;
|
|
142
|
+
try {
|
|
143
|
+
const listed = JSON.parse(execSync(`${bin} workspace list --id-format both --json`,
|
|
144
|
+
{ encoding: "utf8", timeout: 5000, env: { ...process.env, CMUX_QUIET: "1" } }));
|
|
145
|
+
for (const w of listed.workspaces || []) {
|
|
146
|
+
const title = w.custom_title || w.title || "";
|
|
147
|
+
// Title AND directory. Matching on title alone makes this global: a duty instance running
|
|
148
|
+
// with a temp HOME (which is exactly what test-duty-seat.mjs does) would close the REAL
|
|
149
|
+
// seat's workspace and take the production seat down with it. That happened once, on
|
|
150
|
+
// 2026-08-26, and the operator found their duty agent simply gone. Only ever close a
|
|
151
|
+
// workspace that belongs to THIS seat's bus directory.
|
|
152
|
+
if (title === CMUX_WS_NAME && w.id && w.current_directory === DIR) {
|
|
153
|
+
try { execSync(`${bin} close-workspace --workspace ${w.id}`, { stdio: "ignore", timeout: 5000 }); closed++; } catch {}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {}
|
|
157
|
+
return closed;
|
|
158
|
+
}
|
|
159
|
+
|
|
119
160
|
if (cmd === "up") {
|
|
120
161
|
const hub = fleetHub();
|
|
121
162
|
mkdirSync(DIR, { recursive: true });
|
|
@@ -144,9 +185,35 @@ if (cmd === "up") {
|
|
|
144
185
|
const exports = Object.entries(env).map(([k, v]) =>
|
|
145
186
|
`export ${k}=$(cat <<'TRANTOR_${k}_EOF'\n${v}\nTRANTOR_${k}_EOF\n)`).join("\n");
|
|
146
187
|
writeFileSync(launcher, `#!/bin/bash\n# written by \`trantor duty up\` — safe to delete when the seat is down\n${exports}\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(ROOT, "bin", "crew-runner.mjs"))} ${AGENT} ${JSON.stringify(DIR)}\n`, { mode: 0o700 });
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
188
|
+
// PREFER CMUX. Terminal.app was the only surface here, and a plain window is stacking by
|
|
189
|
+
// construction: every `duty up` opens another one and nothing closes the last, so restarts
|
|
190
|
+
// accumulate windows that all look like live duty agents. cmux gives the seat ONE named
|
|
191
|
+
// workspace that gets REPLACED on each up — the same "replace, never stack" rule bin/crew.sh
|
|
192
|
+
// already applies to crew seats, which is why they never pile up and this did.
|
|
193
|
+
//
|
|
194
|
+
// Terminal remains the fallback: no cmux, or its control socket off, and nothing changes.
|
|
195
|
+
const cmuxBin = cmuxBinary();
|
|
196
|
+
|
|
197
|
+
let openedInCmux = false;
|
|
198
|
+
if (cmuxBin) {
|
|
199
|
+
try {
|
|
200
|
+
// Replace, never stack: take the previous duty workspace away before opening this one.
|
|
201
|
+
// Closing first is safe here (unlike a crew pane swap) — the seat is a single surface with
|
|
202
|
+
// nothing to preserve.
|
|
203
|
+
closeDutyWorkspace();
|
|
204
|
+
execSync(`${cmuxBin} new-workspace --name ${JSON.stringify(CMUX_WS_NAME)} --cwd ${JSON.stringify(DIR)} --command ${JSON.stringify(`bash ${launcher}`)} --focus false`,
|
|
205
|
+
{ stdio: "ignore", timeout: 8000, env: { ...process.env, CMUX_QUIET: "1" } });
|
|
206
|
+
openedInCmux = true;
|
|
207
|
+
} catch (e) {
|
|
208
|
+
console.error(`cmux launch failed (${e?.message || e}) — falling back to a Terminal window`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!openedInCmux) {
|
|
213
|
+
const osa = `tell application "Terminal"\n do script ${JSON.stringify(`bash ${launcher}`)}\n activate\nend tell\n`;
|
|
214
|
+
try { execSync(`osascript -e ${JSON.stringify(osa)}`, { stdio: "ignore", timeout: 8000 }); }
|
|
215
|
+
catch (e) { console.error(`could not open a window (${e?.message || e}) — falling back to headless`); }
|
|
216
|
+
}
|
|
150
217
|
// The runner lives inside Terminal, so its pid is not ours to know: find it the same way `down`
|
|
151
218
|
// does. Poll briefly, since Terminal takes a moment to start the shell.
|
|
152
219
|
for (let i = 0; i < 25 && !pid; i++) {
|
|
@@ -163,7 +230,7 @@ if (cmd === "up") {
|
|
|
163
230
|
pid = child.pid;
|
|
164
231
|
}
|
|
165
232
|
writeFileSync(PIDF, String(pid));
|
|
166
|
-
console.log(`— duty agent up: ${SESSION} (pid ${pid})${WINDOW ? " in a
|
|
233
|
+
console.log(`— duty agent up: ${SESSION} (pid ${pid})${WINDOW ? " in a window" : " headless"} on ${DUTY_MODEL === "inherit" ? "the CLI default model" : DUTY_MODEL} watching ${hub} — log: ${LOGF}`);
|
|
167
234
|
const fed = await registerDutySeat(hub, SESSION);
|
|
168
235
|
if (fed) console.log(` hub feeds it: undelivered DMs (>${Math.round(Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 600000) / 60000)}m) + overseer warnings.`);
|
|
169
236
|
process.exit(0); // the seat IS up; a hub that won't feed it is a warning, not a failed start
|
|
@@ -174,6 +241,10 @@ if (cmd === "down") {
|
|
|
174
241
|
if (pid) { try { process.kill(pid); } catch {} console.log(`— duty seat stopped (pid ${pid}) —`); }
|
|
175
242
|
else console.log("no duty seat running");
|
|
176
243
|
try { execSync(`pkill -f "crew-runner.mjs ${AGENT} ${DIR}"`, { stdio: "ignore" }); } catch {}
|
|
244
|
+
// Close the seat's cmux workspace too. Killing the process leaves the surface behind, and a dead
|
|
245
|
+
// pane titled trantor-duty is indistinguishable from a live one at a glance — which is the exact
|
|
246
|
+
// confusion this whole change is about.
|
|
247
|
+
closeDutyWorkspace();
|
|
177
248
|
try { rmSync(PIDF, { force: true }); } catch {}
|
|
178
249
|
// Clear the hub's pointer too — escalations aimed at a seat that no longer exists are messages
|
|
179
250
|
// sent into a hole, and the hub has no other way to learn the seat went away.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.8",
|
|
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": [
|