trantor 0.17.60 → 0.17.62

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.60",
3
+ "version": "0.17.62",
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
@@ -25,6 +25,7 @@ switch (cmd) {
25
25
  case "up": process.argv.splice(2, 1); spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "up", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
26
26
  case "down": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "down", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
27
27
  case "swap": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "swap", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
28
+ case "prune": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "prune", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
28
29
  case "hub": {
29
30
  const sub = args[0];
30
31
  // Per-project hub routing (TDD §12.1): a project lives on exactly ONE hub; codependent
@@ -71,6 +72,7 @@ switch (cmd) {
71
72
  case "policy": run("bin/policy.mjs"); break;
72
73
  case "inbox": run("bin/inbox.mjs"); break;
73
74
  case "duty": run("bin/duty.mjs"); break;
75
+ case "app": run("bin/app.mjs"); break;
74
76
  case "identity": {
75
77
  const { load, publicView, generate, keyPath } = await import(join(ROOT, "lib/identity.mjs"));
76
78
  const sub = args[0], name = args[1] || "human";
@@ -152,7 +154,9 @@ switch (cmd) {
152
154
  trantor models browse live models behind each seat + the router's pick per difficulty
153
155
  trantor up … spawn a crew here: trantor up codex kimi deepseek:deepseek glm:zai-coding-plan
154
156
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
157
+ trantor prune drop dead crew-window tracking rows (ghost workspaces/panes) without spawning anything
155
158
  trantor ui open the live dashboard (board + flow views)
159
+ trantor app the DESKTOP app: status | install | update — pulls the latest DMG from GitHub Releases
156
160
  trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
157
161
  trantor agents what this session's sub-agents did (task · returned? · files written · survived on disk) — [<sessionId>] [--json]
158
162
  trantor gates verification gates: "must verify before shipping" claims that survive handoffs — [--all] [--json]
@@ -167,6 +171,7 @@ switch (cmd) {
167
171
  trantor watch live bus feed in the terminal
168
172
  trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
169
173
  trantor policy the autonomy ladder: show | set <project> <1-4> | link <a> <b> --reason "<why>"
174
+ trantor duty the always-on fleet duty agent: up | down | status — hub-escalated triage so you are not the switchboard
170
175
 
171
176
  Claude Code plugin (the orchestrator side):
172
177
  claude plugin marketplace add sashabogi/trantor && claude plugin install trantor
package/bin/crew.sh CHANGED
@@ -125,6 +125,36 @@ end tell
125
125
  OSA
126
126
  }
127
127
 
128
+ # surgical STATE row removal: drop every row matching PROJECT+KIND (+AGENT/+HANDLE when given).
129
+ # Empty $3/$4 = wildcard. Callers beware: uses _parse_row, which clobbers the RP/RK/RA/RH globals.
130
+ _state_drop() { # $1=project $2=kind $3=agent(''=any) $4=handle(''=any)
131
+ [ "$DRY" = "1" ] && return 0
132
+ [ -f "$STATE" ] || return 0
133
+ local tmp="$STATE.tmp" line
134
+ : > "$tmp"
135
+ while IFS= read -r line; do
136
+ [ -n "$line" ] || continue
137
+ _parse_row "$line"
138
+ if [ "$RP" = "$1" ] && [ "$RK" = "$2" ] && { [ -z "$3" ] || [ "$RA" = "$3" ]; } && { [ -z "$4" ] || [ "$RH" = "$4" ]; }; then continue; fi
139
+ printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
140
+ done < "$STATE"
141
+ mv "$tmp" "$STATE"; [ -s "$STATE" ] || rm -f "$STATE"
142
+ }
143
+
144
+ # Belt-and-suspenders on seat teardown: closing the pane/window SHOULD take the runner with it, but a
145
+ # STALE handle (the 2026-07-30 duplicate-row incident) closes nothing while teardown thinks it's done —
146
+ # the runner survives invisible, still long-polling the inbox. So teardown also kills the seat's
147
+ # processes directly: the per-seat launcher shell, and the runner via the same ANCHORED pattern
148
+ # doctrine as reap_seat (…/<project>$ — a sibling project can never match). CREW_NO_PROC_KILL=1 is the
149
+ # test-suite escape hatch: the suite seeds real project names, and a live crew must survive `npm test`.
150
+ _kill_seat_procs() { # $1=project $2=agent
151
+ [ "${CREW_NO_PROC_KILL:-0}" = "1" ] && return 0
152
+ [ -n "$1" ] && [ -n "$2" ] || return 0
153
+ local pid
154
+ for pid in $(pgrep -f "seats/$1-$2\.sh" 2>/dev/null); do run "kill -9 $pid 2>/dev/null"; done
155
+ for pid in $(pgrep -f "crew-runner\.mjs $2 .*/$1"'$' 2>/dev/null); do run "kill -9 $pid 2>/dev/null"; done
156
+ }
157
+
128
158
  # ── down: PROJECT-SCOPED teardown (never touches another project's crew) ─────────────────────────────
129
159
  usage_down() {
130
160
  cat <<EOF
@@ -187,6 +217,7 @@ down() {
187
217
  fi ;;
188
218
  win|attach) { [ "${#AGENTS[@]}" -gt 0 ] && [ "$K2" = "attach" ]; } && continue; _kill_win "$H2" ;;
189
219
  esac
220
+ case "$K2" in cmuxws|attach) : ;; *) _kill_seat_procs "$P2" "$A2" ;; esac
190
221
  done
191
222
 
192
223
  # rewrite STATE minus the rows we tore down (leaves OTHER projects' rows intact)
@@ -205,13 +236,26 @@ down() {
205
236
  echo "— crew torn down ($SCOPE_DESC)"
206
237
  }
207
238
  [ "$CMD" = "down" ] && { down "$@"; exit $?; }
208
- case "$CMD" in up|swap) ;; *) echo "usage: crew.sh up <agent...> | crew.sh swap <old> <new[:provider[/model]]> | crew.sh down [<agent>...] [--all --yes]"; exit 1 ;; esac
239
+ case "$CMD" in up|swap|prune) ;; *) echo "usage: crew.sh up <agent...> | crew.sh swap <old> <new[:provider[/model]]> | crew.sh down [<agent>...] [--all --yes] | crew.sh prune"; exit 1 ;; esac
209
240
 
210
241
  # self-heal: drop STATE rows whose Terminal window is already gone (dead crews from past sessions), so the
211
- # file doesn't accumulate ghosts across ups. tmux rows are validated by their session existing.
242
+ # file doesn't accumulate ghosts across ups. tmux rows are validated by their session existing; cmux rows
243
+ # by the workspace/surface uuid still existing on the control socket. Socket off (or an empty/unparsable
244
+ # workspace list) ⇒ cmux rows are KEPT — we can't prove death, and they self-heal next time it answers.
212
245
  prune_dead_state() {
213
246
  [ -f "$STATE" ] || return 0
214
247
  [ "$DRY" = "1" ] && return 0
248
+ local CLIVE=""
249
+ 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
258
+ fi
215
259
  local tmp="$STATE.tmp" line alive
216
260
  : > "$tmp"
217
261
  while IFS= read -r line; do
@@ -222,11 +266,15 @@ prune_dead_state() {
222
266
  [ -n "$(osascript -e "tell application \"Terminal\" to get id of (first window whose id is $RH)" 2>/dev/null)" ] || alive=0
223
267
  elif [ "$RK" = "tmux" ]; then
224
268
  tmux has-session -t "trantor:$RP" 2>/dev/null || alive=0
269
+ elif [ "$RK" = "cmuxws" ] || [ "$RK" = "cmux" ]; then
270
+ if [ -n "$CLIVE" ]; then case " $CLIVE " in *" $RH "*) : ;; *) alive=0 ;; esac; fi
225
271
  fi
226
272
  [ "$alive" = "1" ] && printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
227
273
  done < "$STATE"
228
274
  mv "$tmp" "$STATE"; [ -s "$STATE" ] || rm -f "$STATE"
229
275
  }
276
+ # `crew.sh prune` — run the self-heal on demand (ops: clean ghost rows without spawning anything).
277
+ [ "$CMD" = "prune" ] && { prune_dead_state; echo "— pruned dead crew rows ($STATE) —"; exit 0; }
230
278
 
231
279
  # --task/--difficulty drive LAZY live-model selection for provider-only specs (agent:provider).
232
280
  TASK="code"; DIFF="medium"; _ARGS=()
@@ -395,17 +443,86 @@ spawn_cmux() { # $@ = specs
395
443
  echo " ~/.config/cmux/cmux.json (cmux auto-reloads). —"
396
444
  spawn_cmux_applescript "$@"; return
397
445
  fi
446
+ # ── replace, never stack (workspace edition, 2026-08-07) ──────────────────────────────────────────
447
+ # Every `up` used to create a brand-new workspace — no awareness of a crew already on screen — so
448
+ # repeated ups (and the bus-verify RETRY path, which re-enters spawn_crew) stacked dead
449
+ # trantor:<proj> tabs in the sidebar. Observed six deep on crebral-health. Now the NEWEST tracked
450
+ # workspace for this project is REUSED: each spec becomes a fresh pane inside it, replacing any old
451
+ # pane for the same agent; older stacked workspaces are closed. Rows are read even in DRY mode
452
+ # (read-only) so tests can seed a board.
453
+ local REUSE_WS="" line w
454
+ local stale_ws=()
455
+ if [ -f "$STATE" ]; then
456
+ while IFS= read -r line; do
457
+ [ -n "$line" ] || continue
458
+ _parse_row "$line"
459
+ { [ "$RP" = "$PROJ" ] && [ "$RK" = "cmuxws" ]; } || continue
460
+ [ -n "$REUSE_WS" ] && stale_ws+=("$REUSE_WS")
461
+ REUSE_WS="$RH"
462
+ done < "$STATE"
463
+ fi
464
+ if [ "${#stale_ws[@]}" -gt 0 ]; then
465
+ for w in "${stale_ws[@]}"; do
466
+ echo " → closing stale stacked crew workspace for $PROJ ($w)"
467
+ _cmux_close_tab "$w"; _state_drop "$PROJ" "cmuxws" "" "$w"
468
+ done
469
+ prune_dead_state # the closed workspaces' seat rows just died with them
470
+ fi
471
+ # Untracked strays: LIVE workspaces named trantor:<proj> that STATE doesn't know (rows lost, or a
472
+ # pre-fix pileup). Adopt one as the reuse target if we have none; close the rest.
473
+ if [ "$DRY" != "1" ]; then
474
+ local named nid
475
+ named="$(_cmux workspace list --id-format both --json 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||[]);console.log(a.filter(x=>x.custom_title===process.env.WSNAME||x.name===process.env.WSNAME).map(x=>x.id).filter(Boolean).join(" "))}catch(e){}})')"
476
+ for nid in $named; do
477
+ [ "$nid" = "$REUSE_WS" ] && continue
478
+ [ -f "$STATE" ] && grep -qF "$nid" "$STATE" && continue # tracked → handled above
479
+ if [ -z "$REUSE_WS" ]; then
480
+ echo " → adopting existing untracked crew workspace for $PROJ ($nid)"
481
+ REUSE_WS="$nid"; record_state "$PROJ" "cmuxws" "__ws__" "$nid"
482
+ else
483
+ echo " → closing stray crew workspace for $PROJ ($nid)"
484
+ _cmux_close_tab "$nid"
485
+ fi
486
+ done
487
+ fi
398
488
  # Grid tiling: COLS = ceil(sqrt(N)) → 2 seats side-by-side, 4 = 2×2, 6 = 3×2. Row 0 is built with
399
489
  # RIGHT splits off the previous column; each later row splits DOWN from the pane directly above it.
400
490
  # Every split TARGETS a recorded surface id (--surface) — never "whatever pane happens to be focused",
401
- # which is what produced the old staircase layout.
491
+ # which is what produced the old staircase layout. (In REUSE mode a replaced seat splits off its OWN
492
+ # old pane — keeping its spot — and added seats split right off the previous new pane; the fresh-grid
493
+ # math only applies to a fresh workspace.)
402
494
  local N=$# COLS=1; while [ $(( COLS * COLS )) -lt "$N" ]; do COLS=$(( COLS + 1 )); done
403
495
  local SPEC wsid="" surf="" i=0
404
496
  local surfs=()
497
+ [ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
405
498
  for SPEC in "$@"; do
406
499
  resolve_spec "$SPEC"
407
500
  local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
408
- if [ "$i" = "0" ]; then
501
+ if [ -n "$REUSE_WS" ]; then
502
+ # replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
503
+ # then close the old pane — split-first so the workspace can never hit zero surfaces mid-swap.
504
+ local OLD_SURF=""
505
+ if [ -f "$STATE" ]; then
506
+ while IFS= read -r line; do
507
+ [ -n "$line" ] || continue
508
+ _parse_row "$line"
509
+ [ "$RP" = "$PROJ" ] && [ "$RK" = "cmux" ] && [ "$RA" = "$AGENT" ] && OLD_SURF="$RH"
510
+ done < "$STATE"
511
+ fi
512
+ if [ "$DRY" = "1" ]; then
513
+ echo "[dry] cmux: reuse workspace $wsid — new-split for $AGENT${OLD_SURF:+ (replacing $OLD_SURF)}"
514
+ surf="%DRYT$i"
515
+ [ -n "$OLD_SURF" ] && _cmux_close_term "$OLD_SURF"
516
+ else
517
+ local tflag=()
518
+ if [ -n "$OLD_SURF" ]; then tflag=(--surface "$OLD_SURF")
519
+ elif [ "$i" -gt 0 ] && [ -n "${surfs[$(( i - 1 ))]}" ]; then tflag=(--surface "${surfs[$(( i - 1 ))]}"); fi
520
+ surf="$(_cmux new-split right --workspace "$wsid" ${tflag[@]+"${tflag[@]}"} --id-format uuids --json 2>/dev/null | _cmux_surf_json)"
521
+ [ -n "$surf" ] || surf="$(_cmux new-split right --workspace "$wsid" --id-format uuids --json 2>/dev/null | _cmux_surf_json)"
522
+ [ -n "$surf" ] && { _cmux send --surface "$surf" "bash $launcher" >/dev/null 2>&1; _cmux send-key --surface "$surf" enter >/dev/null 2>&1; }
523
+ if [ -n "$OLD_SURF" ]; then _cmux_close_term "$OLD_SURF"; _state_drop "$PROJ" "cmux" "$AGENT" ""; fi
524
+ fi
525
+ elif [ "$i" = "0" ]; then
409
526
  if [ "$DRY" = "1" ]; then
410
527
  echo "[dry] cmux: new-workspace (cwd $DIR) --command 'bash $launcher' → rename 'trantor:$PROJ'"
411
528
  wsid="%DRYWS"; surf="%DRYT0"
@@ -444,10 +561,54 @@ spawn_cmux_applescript() { # $@ = specs
444
561
  local N=$# COLS=1; while [ $(( COLS * COLS )) -lt "$N" ]; do COLS=$(( COLS + 1 )); done
445
562
  local SPEC tabid="" termid="" i=0
446
563
  local terms=()
564
+ # replace, never stack — AppleScript edition. No socket ⇒ no liveness list, so the newest tracked
565
+ # tab is validated by asking cmux for it directly; a dead tracked tab is dropped, older stacked
566
+ # tabs are closed. Reuse then rides the normal split path (i>0) from the first seat.
567
+ local line t REUSE_TAB=""
568
+ local stale_tabs=()
569
+ if [ -f "$STATE" ]; then
570
+ while IFS= read -r line; do
571
+ [ -n "$line" ] || continue
572
+ _parse_row "$line"
573
+ { [ "$RP" = "$PROJ" ] && [ "$RK" = "cmuxws" ]; } || continue
574
+ [ -n "$REUSE_TAB" ] && stale_tabs+=("$REUSE_TAB")
575
+ REUSE_TAB="$RH"
576
+ done < "$STATE"
577
+ fi
578
+ if [ "${#stale_tabs[@]}" -gt 0 ]; then
579
+ for t in "${stale_tabs[@]}"; do
580
+ echo " → closing stale stacked crew workspace for $PROJ ($t)"
581
+ _cmux_close_tab "$t"; _state_drop "$PROJ" "cmuxws" "" "$t"
582
+ done
583
+ fi
584
+ if [ -n "$REUSE_TAB" ] && [ "$DRY" != "1" ]; then
585
+ local ok; ok="$(osascript 2>/dev/null <<OSA
586
+ tell application "cmux"
587
+ repeat with w in windows
588
+ repeat with tt in tabs of w
589
+ if (id of tt) is "$REUSE_TAB" then return "OK"
590
+ end repeat
591
+ end repeat
592
+ return "ERR"
593
+ end tell
594
+ OSA
595
+ )"
596
+ [ "$ok" = "OK" ] || { _state_drop "$PROJ" "cmuxws" "" "$REUSE_TAB"; _state_drop "$PROJ" "cmux" "" ""; REUSE_TAB=""; }
597
+ fi
598
+ [ -n "$REUSE_TAB" ] && { tabid="$REUSE_TAB"; echo " → reusing existing crew workspace for $PROJ ($tabid)"; }
447
599
  for SPEC in "$@"; do
448
600
  resolve_spec "$SPEC"
449
601
  local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
450
- if [ "$i" = "0" ]; then
602
+ # In REUSE mode, replace-in-place: split off the agent's old terminal when tracked, close it after.
603
+ local OLD_SURF=""
604
+ if [ -n "$REUSE_TAB" ] && [ -f "$STATE" ]; then
605
+ while IFS= read -r line; do
606
+ [ -n "$line" ] || continue
607
+ _parse_row "$line"
608
+ [ "$RP" = "$PROJ" ] && [ "$RK" = "cmux" ] && [ "$RA" = "$AGENT" ] && OLD_SURF="$RH"
609
+ done < "$STATE"
610
+ fi
611
+ if [ -z "$tabid" ] && [ "$i" = "0" ]; then
451
612
  if [ "$DRY" = "1" ]; then
452
613
  echo "[dry] cmux(AppleScript): new tab (trantor:$PROJ) + run 'bash $launcher'"; tabid="%DRYTAB"; termid="%DRYT0"
453
614
  else
@@ -470,11 +631,15 @@ OSA
470
631
  fi
471
632
  record_state "$PROJ" "cmuxws" "__ws__" "$tabid"
472
633
  else
473
- local dir target
474
- if [ $(( i / COLS )) = "0" ]; then dir="right"; target="${terms[$(( i - 1 ))]}"
475
- else dir="down"; target="${terms[$(( i - COLS ))]}"; fi
634
+ local dir target=""
635
+ if [ -n "$OLD_SURF" ]; then dir="right"; target="$OLD_SURF" # replace-in-place: keep the seat's spot
636
+ elif [ "$i" -gt 0 ]; then
637
+ if [ $(( i / COLS )) = "0" ]; then dir="right"; target="${terms[$(( i - 1 ))]}"
638
+ else dir="down"; target="${terms[$(( i - COLS ))]}"; fi
639
+ else dir="right"; fi # reuse mode, first seat, nothing tracked → focused terminal
476
640
  if [ "$DRY" = "1" ]; then
477
641
  echo "[dry] cmux(AppleScript): split $dir from ${target:-<focused>} + run 'bash $launcher'"; termid="%DRYT$i"
642
+ [ -n "$OLD_SURF" ] && _cmux_close_term "$OLD_SURF"
478
643
  else
479
644
  termid="$(osascript 2>/dev/null <<OSA
480
645
  tell application "cmux"
@@ -497,6 +662,7 @@ tell application "cmux"
497
662
  end tell
498
663
  OSA
499
664
  )"
665
+ [ -n "$OLD_SURF" ] && [ -n "$termid" ] && [ "$termid" != "ERR" ] && { _cmux_close_term "$OLD_SURF"; _state_drop "$PROJ" "cmux" "$AGENT" ""; }
500
666
  fi
501
667
  fi
502
668
  terms+=("$termid")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.60",
3
+ "version": "0.17.62",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"