trantor 0.17.59 → 0.17.61
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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.mjs +3 -0
- package/bin/crew-runner.mjs +3 -1
- package/bin/crew.sh +174 -8
- package/bin/duty.mjs +111 -0
- package/hub.mjs +36 -0
- package/package.json +2 -2
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"name": "trantor",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, GLM, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
|
|
16
|
-
"version": "0.17.
|
|
16
|
+
"version": "0.17.60",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Sasha Bogojevic"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.61",
|
|
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/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
|
|
@@ -70,6 +71,7 @@ switch (cmd) {
|
|
|
70
71
|
case "summarize": run("bin/summarize.mjs"); break;
|
|
71
72
|
case "policy": run("bin/policy.mjs"); break;
|
|
72
73
|
case "inbox": run("bin/inbox.mjs"); break;
|
|
74
|
+
case "duty": run("bin/duty.mjs"); break;
|
|
73
75
|
case "identity": {
|
|
74
76
|
const { load, publicView, generate, keyPath } = await import(join(ROOT, "lib/identity.mjs"));
|
|
75
77
|
const sub = args[0], name = args[1] || "human";
|
|
@@ -166,6 +168,7 @@ switch (cmd) {
|
|
|
166
168
|
trantor watch live bus feed in the terminal
|
|
167
169
|
trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
|
|
168
170
|
trantor policy the autonomy ladder: show | set <project> <1-4> | link <a> <b> --reason "<why>"
|
|
171
|
+
trantor duty the always-on fleet duty agent: up | down | status — hub-escalated triage so you are not the switchboard
|
|
169
172
|
|
|
170
173
|
Claude Code plugin (the orchestrator side):
|
|
171
174
|
claude plugin marketplace add sashabogi/trantor && claude plugin install trantor
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -136,7 +136,9 @@ const cli = CLI[AGENT] || (NATIVE.has(AGENT) ? null : CLI.opencode);
|
|
|
136
136
|
if (!cli) { console.error(`unknown agent '${AGENT}' (native: ${[...NATIVE].join(", ")}; any other name = an opencode provider seat)`); process.exit(1); }
|
|
137
137
|
if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an opencode provider (BYOM)`);
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
// RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
|
|
140
|
+
// always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
|
|
141
|
+
const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go (doing -> testing -> done; run the tests in 'testing', use 'failed' + a report if they break). When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message.`;
|
|
140
142
|
|
|
141
143
|
// ---- failure visibility ----------------------------------------------------
|
|
142
144
|
// A turn's CLI can fail (credits exhausted, auth, crash) and the runner would just
|
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 [ "$
|
|
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
|
-
|
|
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 [
|
|
475
|
-
|
|
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/bin/duty.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor duty — the always-on fleet DUTY AGENT: one seat that watches the whole hub and triages
|
|
3
|
+
// so the human never has to be a switchboard.
|
|
4
|
+
//
|
|
5
|
+
// trantor duty up [--hub <url>] [--agent claude] start (idempotent — reaps a prior seat)
|
|
6
|
+
// trantor duty down stop
|
|
7
|
+
// trantor duty status pid + last turns + presence
|
|
8
|
+
//
|
|
9
|
+
// Division of labor (the overseer doctrine, extended): DETECTION stays mechanical and hub-side —
|
|
10
|
+
// RELAY_DUTY_SESSION makes the hub DM this seat when a direct message sits undelivered past
|
|
11
|
+
// RELAY_DUTY_UNDELIVERED_MS or the overseer emits a warning. The SEAT only triages: relay, wake,
|
|
12
|
+
// annotate, and only involves the human when a real decision is needed. It runs under the same
|
|
13
|
+
// crew-runner that keeps crew seats alive (long-poll wake, turn telemetry, failure reporting) —
|
|
14
|
+
// just with a triage doctrine instead of "work your card" (RUNNER_RULES / CREW_KICKOFF).
|
|
15
|
+
import { spawn, execSync } from "node:child_process";
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, rmSync } from "node:fs";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
21
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
22
|
+
|
|
23
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
24
|
+
const BUS = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
25
|
+
const DIR = join(BUS, "fleet"); // the seat's cwd — project name "fleet"
|
|
26
|
+
const PIDF = join(BUS, "duty.pid");
|
|
27
|
+
const LOGF = join(BUS, "duty.log");
|
|
28
|
+
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
const cmd = argv[0] || "status";
|
|
31
|
+
const val = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] || d) : d; };
|
|
32
|
+
|
|
33
|
+
let config = {}; try { config = JSON.parse(readFileSync(join(BUS, "config.json"), "utf8")); } catch {}
|
|
34
|
+
// The fleet hub: where the projects live. Default = the most common per-project hub in config,
|
|
35
|
+
// falling back to the global url — a fleet watcher belongs where the fleet is.
|
|
36
|
+
function fleetHub() {
|
|
37
|
+
const counts = new Map();
|
|
38
|
+
for (const u of Object.values(config.hubs || {})) counts.set(u, (counts.get(u) || 0) + 1);
|
|
39
|
+
const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
40
|
+
return val("hub", top?.[0] || config.url || "http://127.0.0.1:4477");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const AGENT = val("agent", "claude");
|
|
44
|
+
const SESSION = `${AGENT}:fleet`;
|
|
45
|
+
|
|
46
|
+
const RULES = `Rules: you are ${SESSION}, the trantor fleet DUTY AGENT — the always-on triage seat. You NEVER write code and NEVER edit project files. On every wake: (1) read the message(s) that woke you; (2) triage with your relay tools — relay_peers for who is live/down, relay_board with the project param for any board, relay_inbox for your own backlog; runner logs live at ~/.agent-bus/logs/<agent>-<project>.jsonl if a seat looks dead; (3) ACT: an UNDELIVERED escalation means the recipient is idle, deaf (wrong hub / stale hooks — a known failure mode), or gone — relay the content to a live session that can act, wake a crew seat with a direct message, or post the information into the project lane so the human's app notifies them; an OVERSEER warning means two parties may collide — message them to coordinate; a seat reported down/errored — check its log tail and either resend its contract or report exactly what is needed. (4) Report each action in ONE bus message (<280 chars) to the lane it concerns. If only a human can decide, say exactly that, in that lane, once. Then END YOUR TURN — the runner wakes you for the next event.`;
|
|
47
|
+
|
|
48
|
+
const KICKOFF = `You are ${SESSION}, the fleet duty agent, freshly started. Do a short patrol: relay_peers (note anything down/errored), then relay_inbox. Handle what is actionable per the Rules, post one line to the bus saying the duty seat is on watch, and end your turn.\n\n${RULES}`;
|
|
49
|
+
|
|
50
|
+
async function ensureFleetIdentity(hub) {
|
|
51
|
+
const id = loadOrCreate(SESSION, "agent");
|
|
52
|
+
const probe = await sfetchJson(`${hub}/peers`, { method: "GET", identity: id, signal: AbortSignal.timeout(5000) }).catch(() => null);
|
|
53
|
+
if (probe && probe.status !== 401) return true; // enrolled (or hub not enforcing)
|
|
54
|
+
// Not enrolled: mint an owner invite with FLEET-WIDE write (write ⊇ read) and enroll with it.
|
|
55
|
+
const owner = loadOrCreate(config.ownerIdentity || "admin", "human");
|
|
56
|
+
const inv = await sfetchJson(`${hub}/invite`, { identity: owner, payload: { scopes: [{ project: "*", role: "write" }], ttlSec: 3600 }, signal: AbortSignal.timeout(5000) });
|
|
57
|
+
const invJson = await inv.json().catch(() => ({}));
|
|
58
|
+
if (!inv.ok || !invJson.token) { console.error(`could not mint fleet invite on ${hub}: ${invJson.error || inv.status} — is ${config.ownerIdentity || "admin"} the owner there?`); return false; }
|
|
59
|
+
const enr = await sfetchJson(`${hub}/enroll`, { identity: id, payload: { token: invJson.token, pubkey: id.pubkey, name: SESSION, kind: "agent" }, signal: AbortSignal.timeout(5000) });
|
|
60
|
+
const enrJson = await enr.json().catch(() => ({}));
|
|
61
|
+
if (!enr.ok) { console.error(`fleet enroll failed: ${enrJson.error || enr.status}`); return false; }
|
|
62
|
+
console.log(`— enrolled ${SESSION} on ${hub} with fleet-wide write —`);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function alivePid() {
|
|
67
|
+
try {
|
|
68
|
+
const pid = Number(readFileSync(PIDF, "utf8"));
|
|
69
|
+
if (pid && process.kill(pid, 0) === undefined) return pid;
|
|
70
|
+
} catch {}
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (cmd === "up") {
|
|
75
|
+
const hub = fleetHub();
|
|
76
|
+
mkdirSync(DIR, { recursive: true });
|
|
77
|
+
const prior = alivePid();
|
|
78
|
+
if (prior) { console.log(`— reaping prior duty seat (pid ${prior}) —`); try { process.kill(prior); } catch {} }
|
|
79
|
+
try { execSync(`pkill -f "crew-runner.mjs ${AGENT} ${DIR}"`, { stdio: "ignore" }); } catch {}
|
|
80
|
+
if (!(await ensureFleetIdentity(hub))) process.exit(1);
|
|
81
|
+
const out = openSync(LOGF, "a");
|
|
82
|
+
const child = spawn(process.execPath, [join(ROOT, "bin", "crew-runner.mjs"), AGENT, DIR], {
|
|
83
|
+
detached: true, stdio: ["ignore", out, out],
|
|
84
|
+
env: { ...process.env, RELAY_URL: hub, RUNNER_RULES: RULES, CREW_KICKOFF: KICKOFF },
|
|
85
|
+
});
|
|
86
|
+
child.unref();
|
|
87
|
+
writeFileSync(PIDF, String(child.pid));
|
|
88
|
+
console.log(`— duty agent up: ${SESSION} (pid ${child.pid}) watching ${hub} — log: ${LOGF}`);
|
|
89
|
+
console.log(` hub feeds it: undelivered DMs (>${Math.round(Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 600000) / 60000)}m) + overseer warnings — set RELAY_DUTY_SESSION=${SESSION} on the hub service.`);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (cmd === "down") {
|
|
94
|
+
const pid = alivePid();
|
|
95
|
+
if (pid) { try { process.kill(pid); } catch {} console.log(`— duty seat stopped (pid ${pid}) —`); }
|
|
96
|
+
else console.log("no duty seat running");
|
|
97
|
+
try { execSync(`pkill -f "crew-runner.mjs ${AGENT} ${DIR}"`, { stdio: "ignore" }); } catch {}
|
|
98
|
+
try { rmSync(PIDF, { force: true }); } catch {}
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// status
|
|
103
|
+
{
|
|
104
|
+
const pid = alivePid();
|
|
105
|
+
console.log(pid ? `duty seat RUNNING (pid ${pid}) as ${SESSION}` : "duty seat NOT running");
|
|
106
|
+
try {
|
|
107
|
+
const lines = readFileSync(join(BUS, "logs", `${AGENT}-fleet.jsonl`), "utf8").trim().split("\n").slice(-3);
|
|
108
|
+
console.log("last turns:"); for (const l of lines) console.log(` ${l}`);
|
|
109
|
+
} catch { console.log("(no turns logged yet)"); }
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
package/hub.mjs
CHANGED
|
@@ -245,6 +245,7 @@ function overseerTick() {
|
|
|
245
245
|
overseerWarned.set(key, now());
|
|
246
246
|
appendEvent("overseer.warn", c.project, "overseer",
|
|
247
247
|
{ kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
|
|
248
|
+
if (DUTY_SESSION) hubSend(DUTY_SESSION, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
|
|
248
249
|
const level = _overseer.levelFor ? _overseer.levelFor(c.project, pol.autonomy) : 1;
|
|
249
250
|
if (level >= 3 && c.kind === "file-conflict") {
|
|
250
251
|
const g = { id: ++state.verifyGateSeq, project: c.project, status: "open", ts: now(),
|
|
@@ -257,6 +258,41 @@ function overseerTick() {
|
|
|
257
258
|
}
|
|
258
259
|
setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
|
|
259
260
|
|
|
261
|
+
// --- the DUTY AGENT feed (deterministic escalation; the seat itself is bin/duty.mjs) -----------
|
|
262
|
+
// RELAY_DUTY_SESSION names the always-on triage seat (e.g. "claude:fleet"). The hub DMs it when:
|
|
263
|
+
// 1. a DIRECT message has sat undelivered past RELAY_DUTY_UNDELIVERED_MS — the sender believes
|
|
264
|
+
// it was heard and nobody is listening (the crebral-health 4-day failure mode);
|
|
265
|
+
// 2. the overseer emits a warning (wired inside overseerTick below).
|
|
266
|
+
// Escalations are hub-authored ("hub:duty") — they never impersonate a session — and dedup per
|
|
267
|
+
// message id so a standing outage escalates once, not every tick.
|
|
268
|
+
const DUTY_SESSION = String(process.env.RELAY_DUTY_SESSION || "");
|
|
269
|
+
const DUTY_UNDELIVERED_MS = Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 10 * 60 * 1000);
|
|
270
|
+
const dutyEscalated = new Set();
|
|
271
|
+
function hubSend(to, text, project) {
|
|
272
|
+
const msg = { id: ++state.seq, ts: now(), from: "hub:duty", to, text: String(text).slice(0, 2000), project: String(project || "").slice(0, 80) };
|
|
273
|
+
state.messages.push(msg); if (state.messages.length > 5000) state.messages.splice(0, 1000);
|
|
274
|
+
dirty = true; pushToStreams(msg);
|
|
275
|
+
appendEvent("message", msg.project, msg.from, { msgId: msg.id, toSession: msg.to, text: msg.text.slice(0, 2000), refs: [] });
|
|
276
|
+
return msg;
|
|
277
|
+
}
|
|
278
|
+
function dutyTick() {
|
|
279
|
+
if (!DUTY_SESSION) return;
|
|
280
|
+
const cutoff = now() - DUTY_UNDELIVERED_MS;
|
|
281
|
+
const floor = now() - 24 * 3600 * 1000; // never escalate ancient history
|
|
282
|
+
for (const m of state.messages) {
|
|
283
|
+
if (m.ts > cutoff || m.ts < floor) continue;
|
|
284
|
+
if (!m.to || m.to === "all" || m.to === DUTY_SESSION || m.from === "hub:duty") continue;
|
|
285
|
+
if (dutyEscalated.has(m.id)) continue;
|
|
286
|
+
if ((state.peers[m.to]?.deliveredUpTo || 0) >= m.id) continue;
|
|
287
|
+
dutyEscalated.add(m.id);
|
|
288
|
+
hubSend(DUTY_SESSION,
|
|
289
|
+
`⚠️ UNDELIVERED for ${Math.round((now() - m.ts) / 60000)}m: #${m.id} ${m.from} -> ${m.to} — "${String(m.text).slice(0, 280)}" — the recipient has not been handed this. Triage: is the recipient's session idle, deaf (wrong hub / old hooks), or gone? Relay, wake, or note it on their board.`,
|
|
290
|
+
m.project || "");
|
|
291
|
+
}
|
|
292
|
+
if (dutyEscalated.size > 5000) { let n = dutyEscalated.size - 4000; for (const k of dutyEscalated) { dutyEscalated.delete(k); if (--n <= 0) break; } }
|
|
293
|
+
}
|
|
294
|
+
setInterval(dutyTick, OVERSEER_TICK_MS).unref?.();
|
|
295
|
+
|
|
260
296
|
if (durableStore?.subscribeChanges) {
|
|
261
297
|
durableStore.subscribeChanges((p) => {
|
|
262
298
|
if (p && p.src === HUB_SRC) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.61",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"pg": "^8.22.0"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && bash test-crew.sh"
|
|
14
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && bash test-crew.sh"
|
|
15
15
|
},
|
|
16
16
|
"description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
17
17
|
"files": [
|