trantor 0.18.6 → 0.18.7

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.18.6",
3
+ "version": "0.18.7",
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": {
@@ -326,7 +326,18 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
326
326
  const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | tee -a ${ERRF}`;
327
327
  const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(tee -a ${ERRF} >&2)`], {
328
328
  cwd: DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
329
- env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ },
329
+ env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
330
+ // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
331
+ //
332
+ // The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
333
+ // handoff and opens a fresh window to carry on. A seat has no use for that — the runner is its
334
+ // lifecycle manager and wakes it per event — so the spawn just leaks an unmanaged interactive
335
+ // session into a window nobody asked for.
336
+ //
337
+ // Observed on the duty seat: handoff records at 17:24 and 18:59 on 2026-08-24, and two stray
338
+ // `claude` processes in ~/.agent-bus/trantor-duty started at 17:24:57 and 18:59:50, still
339
+ // sitting there days later. To the operator that reads as "why are there two duty agents".
340
+ TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
330
341
  maxBuffer: 16 * 1024 * 1024,
331
342
  });
332
343
  try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
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
- const osa = `tell application "Terminal"\n do script ${JSON.stringify(`bash ${launcher}`)}\n activate\nend tell\n`;
148
- try { execSync(`osascript -e ${JSON.stringify(osa)}`, { stdio: "ignore", timeout: 8000 }); }
149
- catch (e) { console.error(`could not open a window (${e?.message || e}) falling back to headless`); }
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 Terminal window" : " headless"} on ${DUTY_MODEL === "inherit" ? "the CLI default model" : DUTY_MODEL} watching ${hub} — log: ${LOGF}`);
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.6",
3
+ "version": "0.18.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"