trantor 0.18.96 → 0.18.98

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.96",
3
+ "version": "0.18.98",
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/crew/core.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
@@ -85,8 +85,20 @@ function selectMux(env, have) {
85
85
  return "terminal";
86
86
  }
87
87
 
88
+ // PWD keeps a symlinked spelling of the cwd, so prefer it, but only while it still names the cwd.
89
+ // A parent that spawns with a different cwd leaves its own PWD behind: the app relaunched from a
90
+ // trantor shell ran every `trantor open <other>` as trantor and the handoff refused.
91
+ export function workingDir(env = process.env, cwd = process.cwd()) {
92
+ if (!env.PWD) return cwd;
93
+ try {
94
+ return realpathSync(env.PWD) === realpathSync(cwd) ? env.PWD : cwd;
95
+ } catch {
96
+ return cwd;
97
+ }
98
+ }
99
+
88
100
  export function createContext(command, env = process.env) {
89
- const dir = env.PWD || process.cwd();
101
+ const dir = workingDir(env);
90
102
  const home = env.HOME || "";
91
103
  const project = env.RELAY_PROJECT || projectFromGit(dir);
92
104
  const have = {
package/bin/crew/open.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, openSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { existsSync, openSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, join } from "node:path";
5
5
  import { spawn, execSync } from "node:child_process";
@@ -43,19 +43,44 @@ function sessionId(ctx) {
43
43
  return id;
44
44
  }
45
45
 
46
- export function hasPendingHandoff(ctx) {
46
+ // #11641: a handoff is stale once its own session kept working after writing it (auto handoffs fire
47
+ // at a context threshold, not at the end). Slack so the write itself, and the hook flushing the
48
+ // transcript around it, never read as "later work".
49
+ export const HANDOFF_SUPERSEDE_GRACE_SECS = 120;
50
+
51
+ function unconsumedHandoffs(ctx) {
47
52
  const dir = join(ctx.env.AGENT_BUS_DIR || ctx.env.RELAY_DATA_DIR || join(ctx.home, ".agent-bus"), "handoffs");
48
- if (!existsSync(dir)) return false;
53
+ if (!existsSync(dir)) return [];
49
54
  const prefix = `${ctx.project}-`;
55
+ const out = [];
50
56
  for (const file of readdirSync(dir).filter(name => name.startsWith(prefix) && name.endsWith(".json")).sort().reverse()) {
51
- try { if (!JSON.parse(readFileSync(join(dir, file), "utf8")).consumed) return true; }
57
+ try { const rec = JSON.parse(readFileSync(join(dir, file), "utf8")); if (!rec.consumed) out.push({ file, rec }); }
52
58
  catch {}
53
59
  }
54
- return false;
60
+ return out;
61
+ }
62
+
63
+ // The handoff written BY the recorded session `id` whose transcript has since grown past it, or null.
64
+ export function supersededHandoff(ctx, id) {
65
+ for (const { file, rec } of unconsumedHandoffs(ctx)) {
66
+ if (!id || rec.session_id !== id || !(Number(rec.stamp) > 0)) continue;
67
+ const slug = String(rec.project || ctx.dir || "").replace(/[/.]/g, "-");
68
+ const transcript = rec.transcript_path || join(ctx.home, ".claude", "projects", slug, `${id}.jsonl`);
69
+ try {
70
+ const workedAt = statSync(transcript).mtimeMs / 1000;
71
+ if (workedAt > Number(rec.stamp) + HANDOFF_SUPERSEDE_GRACE_SECS) return { file, handoffAt: Number(rec.stamp), workedAt };
72
+ } catch {}
73
+ }
74
+ return null;
75
+ }
76
+
77
+ export function hasPendingHandoff(ctx, id) {
78
+ const sup = id ? supersededHandoff(ctx, id) : null;
79
+ return unconsumedHandoffs(ctx).some(({ file }) => !sup || file !== sup.file);
55
80
  }
56
81
 
57
82
  export function takeoverSessionId(ctx, id) {
58
- return hasPendingHandoff(ctx) ? randomUUID() : id;
83
+ return hasPendingHandoff(ctx, id) ? randomUUID() : id;
59
84
  }
60
85
 
61
86
  function harnessFlag(ctx) {
@@ -201,7 +226,12 @@ export function openOrchestrator(ctx, args) {
201
226
  if (!ctx.dry) maybeBuildGraft(ctx.dir);
202
227
  if (!ctx.have.herdr) throw new Error("trantor open needs herdr (the pane host) — install: curl -fsSL https://herdr.dev/install.sh | sh");
203
228
  let id = sessionId(ctx);
204
- if (hasPendingHandoff(ctx)) {
229
+ const stale = supersededHandoff(ctx, id);
230
+ if (stale) {
231
+ const at = secs => new Date(secs * 1000).toISOString();
232
+ console.error(`— handoff ${stale.file} was superseded by later work in session ${id} (handoff ${at(stale.handoffAt)}, transcript ${at(stale.workedAt)}): resuming it, leaving the handoff unclaimed —`);
233
+ }
234
+ if (hasPendingHandoff(ctx, id)) {
205
235
  const fresh = takeoverSessionId(ctx, id);
206
236
  console.error(`— recorded session ${id} handed off: starting fresh as ${fresh} to claim it —`);
207
237
  id = fresh;
@@ -30,6 +30,7 @@ import {
30
30
  import { snapshotBeforeCut } from "./cut-snapshot.mjs";
31
31
  import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
32
32
  import { ocCompactionContent, ocCompactionLogLine } from "../lib/seat-compaction.mjs";
33
+ import { seatSignalPrefix } from "../lib/seat-signal-guard.mjs";
33
34
  import { listContainers, newContainers, recordContainers, statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../lib/docker-janitor.mjs";
34
35
  import {
35
36
  auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
@@ -834,7 +835,7 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
834
835
  // #6393: the store's keys ride the spawn env under shadow names and the shell re-exports them
835
836
  // AFTER the sources ran, so the keychain wins over a stale .env line and no file is ever copied.
836
837
  const secrets = resolveSecrets(process.env);
837
- cmd = withEnvFiles(withSecretExports(cmd, Object.keys(secrets)), envs);
838
+ cmd = withEnvFiles(withSecretExports(seatSignalPrefix(TURN_DIR) + cmd, Object.keys(secrets)), envs);
838
839
  log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
839
840
  // #7777: the one effort line — which effort parameters were set, or that the model is uncatalogued.
840
841
  if (EFFORT && EFFORT_FLAG.text) log(EFFORT_FLAG.text);
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { constants } from "node:os";
4
+ import { basename } from "node:path";
5
+
6
+ const [command, ...args] = process.argv.slice(2);
7
+ const refuse = reason => { throw new Error(reason); };
8
+
9
+ function signalValue(value) {
10
+ if (/^\d+$/.test(value)) {
11
+ const n = Number(value);
12
+ if (n === 0 || Object.values(constants.signals).includes(n)) return n;
13
+ }
14
+ const name = value.toUpperCase().replace(/^SIG/, "");
15
+ if (constants.signals[`SIG${name}`]) return constants.signals[`SIG${name}`];
16
+ return refuse("unknown signal");
17
+ }
18
+
19
+ function processTable() {
20
+ const ps = spawnSync("/bin/ps", ["-axo", "pid=,ppid=,comm="], { encoding: "utf8", timeout: 5000 });
21
+ if (ps.error || ps.status !== 0) refuse("cannot inspect process ancestry");
22
+ const rows = new Map();
23
+ for (const line of ps.stdout.split("\n")) {
24
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
25
+ if (match) rows.set(Number(match[1]), { parent: Number(match[2]), name: basename(match[3].trim()) });
26
+ }
27
+ return rows;
28
+ }
29
+
30
+ function descendant(pid, root, rows) {
31
+ const seen = new Set();
32
+ while (pid !== root && rows.has(pid) && !seen.has(pid)) {
33
+ seen.add(pid);
34
+ pid = rows.get(pid).parent;
35
+ }
36
+ return seen.size > 0 && pid === root;
37
+ }
38
+
39
+ function targets(rows) {
40
+ let signal = constants.signals.SIGTERM;
41
+ let at = 0;
42
+ if (["-s", "--signal"].includes(args[at])) {
43
+ if (!args[at + 1]) refuse("missing signal");
44
+ signal = signalValue(args[at + 1]); at += 2;
45
+ } else if (/^-(?:\d+|(?:SIG)?[A-Z]+)$/i.test(args[at] || "") && !/^-[fix]+$/.test(args[at])) {
46
+ signal = signalValue(args[at].slice(1)); at++;
47
+ }
48
+ if (command === "kill") {
49
+ if (args[at] === "--") at++;
50
+ const pids = args.slice(at);
51
+ if (!pids.length || pids.some(p => !/^[1-9]\d*$/.test(p) || !Number.isSafeInteger(Number(p)))) {
52
+ refuse("kill requires positive PIDs; process groups and job specs are refused");
53
+ }
54
+ return { signal, pids: pids.map(Number) };
55
+ }
56
+ if (command === "pkill") {
57
+ const flags = [];
58
+ while (/^-[fix]+$/.test(args[at] || "")) flags.push(args[at++]);
59
+ if (args.length - at !== 1 || args[at].startsWith("-")) {
60
+ refuse("pkill requires options before one pattern; ambiguous or unsupported arguments");
61
+ }
62
+ // pgrep resolves native regex semantics without ever invoking the signalling binary.
63
+ const result = spawnSync("/usr/bin/pgrep", [...flags, args[at]], { encoding: "utf8", timeout: 5000 });
64
+ if (result.error || ![0, 1].includes(result.status)) refuse("cannot resolve pkill pattern");
65
+ return { signal, pids: result.stdout.trim().split(/\s+/).filter(Boolean).map(Number) };
66
+ }
67
+ if (command === "killall") {
68
+ const names = args.slice(at);
69
+ if (!names.length || names.some(name => name.startsWith("-") || name.includes("/"))) {
70
+ refuse("killall requires exact process names; unsupported arguments");
71
+ }
72
+ return { signal, pids: [...rows].filter(([, row]) => names.includes(row.name)).map(([pid]) => pid) };
73
+ }
74
+ return refuse("unknown signal command");
75
+ }
76
+
77
+ try {
78
+ const root = Number(process.env.TRANTOR_SIGNAL_ROOT);
79
+ if (!Number.isSafeInteger(root) || root <= 1) refuse("missing seat process root");
80
+ const rows = processTable();
81
+ if (!descendant(process.pid, root, rows)) refuse("guard is outside the seat process tree");
82
+ const selected = targets(rows);
83
+ const pids = [...new Set(selected.pids)].filter(pid => pid !== process.pid);
84
+ if (!pids.length) process.exit(1);
85
+ for (const pid of pids) {
86
+ if (!descendant(pid, root, rows)) refuse(`PID ${pid} is outside the seat process tree`);
87
+ }
88
+ // Recheck the whole set before sending anything; never partially apply a broad match.
89
+ const current = processTable();
90
+ for (const pid of pids) {
91
+ if (!descendant(pid, root, current)) refuse(`PID ${pid} left the seat process tree`);
92
+ }
93
+ for (const pid of pids) process.kill(pid, selected.signal);
94
+ } catch (error) {
95
+ console.error(`seat signal guard: refused ${command}: ${error.message}`);
96
+ process.exitCode = 1;
97
+ }
@@ -0,0 +1,30 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
6
+
7
+ // Install after env files are sourced so a provider's PATH cannot hide the guard.
8
+ export function seatSignalPrefix(workdir, env = process.env, root = process.pid) {
9
+ const parent = join(workdir, ".agent-bus-out", "signal-guard");
10
+ const dir = join(parent, String(root));
11
+ mkdirSync(dir, { recursive: true });
12
+ // Runtime shims must not count as seat work or enter a cut snapshot in arbitrary repositories.
13
+ writeFileSync(join(parent, ".gitignore"), "*\n");
14
+ const guard = fileURLToPath(new URL("../bin/seat-signal.mjs", import.meta.url));
15
+ for (const name of ["kill", "pkill", "killall"]) {
16
+ writeFileSync(join(dir, name), `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(guard)} ${name} "$@"\n`, { mode: 0o755 });
17
+ }
18
+ const path = `export PATH=${quote(dir)}:"$PATH"\n`;
19
+ const bash = join(dir, "bash-env");
20
+ const previousBash = env.BASH_ENV && env.BASH_ENV !== bash
21
+ ? `[ ! -f ${quote(env.BASH_ENV)} ] || . ${quote(env.BASH_ENV)}\n` : "";
22
+ writeFileSync(bash, `${previousBash}${path}enable -n kill\n`);
23
+ const previousZdot = env.ZDOTDIR || env.HOME;
24
+ for (const name of [".zshenv", ".zprofile", ".zshrc", ".zlogin"]) {
25
+ const original = previousZdot && previousZdot !== dir ? join(previousZdot, name) : "";
26
+ const source = original ? `[ ! -f ${quote(original)} ] || . ${quote(original)}\n` : "";
27
+ writeFileSync(join(dir, name), `${source}${path}disable kill\n`);
28
+ }
29
+ return `export TRANTOR_SIGNAL_ROOT=${quote(root)} BASH_ENV=${quote(bash)} ZDOTDIR=${quote(dir)}\n${path}enable -n kill\n`;
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.96",
3
+ "version": "0.18.98",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"