trantor 0.17.96 → 0.17.97

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.96",
3
+ "version": "0.17.97",
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": {
@@ -18,13 +18,14 @@ import { join, basename, dirname } from "node:path";
18
18
  import { homedir, hostname } from "node:os";
19
19
  import { spawn } from "node:child_process";
20
20
  import { fileURLToPath } from "node:url";
21
- import { readConfig, contextUsage, warnFrac, alreadyHandedOff, markHandedOff, controllingTty, terminalWindowForTty, subagentsActive } from "./lib/handoff.mjs";
21
+ import { armBaton, readArm, clearArm, readConfig, contextUsage, warnFrac, alreadyHandedOff, markHandedOff, controllingTty, terminalWindowForTty, subagentsActive } from "./lib/handoff.mjs";
22
22
  import { resolveProject, hostId } from "../lib/project.mjs";
23
23
  import { installedVersion } from "./lib/update-check.mjs"; // report our hook version so the hub can flag stale sessions
24
24
  import { signedPost } from "./lib/api.mjs";
25
25
 
26
26
  const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
27
27
  const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
28
+ const ARM_MAX_MS = Number(process.env.TRANTOR_BATON_ARM_MAX_MS || 15 * 60 * 1000);
28
29
  const INFLIGHT_MS = 5 * 60 * 1000;
29
30
  const HERE = dirname(fileURLToPath(import.meta.url));
30
31
 
@@ -83,25 +84,56 @@ async function maybeEarlyWarn(stdinRaw, session) {
83
84
 
84
85
  // In-flight guard: the detached worker takes ~tens of seconds to summarize;
85
86
  // don't launch a second one on the next heartbeat tick meanwhile.
87
+ // NOTE the ordering: this debounce guards the SPAWN, not the arming. It used to sit here and
88
+ // return before any of the logic below, which meant the arm/backstop path never ran a second
89
+ // time and a session that reached no turn boundary would stay armed forever. Arming is cheap
90
+ // and idempotent; only launching the worker needs debouncing.
86
91
  const inflight = join(homedir(), ".agent-bus", `handoff-inflight-${String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "_")}.stamp`);
87
- try { if (existsSync(inflight) && Date.now() - (Number(readFileSync(inflight, "utf8")) || 0) < INFLIGHT_MS) return; } catch {}
88
- try { writeFileSync(inflight, String(Date.now())); } catch {}
92
+ const spawnDebounced = () => {
93
+ try { if (existsSync(inflight) && Date.now() - (Number(readFileSync(inflight, "utf8")) || 0) < INFLIGHT_MS) return false; } catch {}
94
+ try { writeFileSync(inflight, String(Date.now())); } catch {}
95
+ return true;
96
+ };
89
97
 
90
98
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
91
99
  // Detect THIS session's Terminal window NOW (the hook has the controlling tty; the detached worker
92
100
  // won't) so the baton-close can replace this exact window once the fresh session takes over.
93
101
  const tty = controllingTty();
94
102
  const windowId = tty ? terminalWindowForTty(tty) : "";
95
- process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% of ${usage.window} baton pass (window ${windowId || "?"})\n`);
96
- const child = spawn(process.execPath, [join(HERE, "handoff-now.mjs"), projectDir, sessionId, transcript, "context-warn", windowId, tty],
97
- { detached: true, stdio: "ignore" });
98
- child.unref();
99
- // Persistent per-window guard — the SAME one precompact uses. Without this the early-warning was
100
- // gated ONLY by the 5-minute inflight stamp, so a session parked above the warn line re-fired every
101
- // 5 minutes: a STORM of handoffs + a new fresh window + a new baton-close each tick (seen as 8 stacked
102
- // handoffs ~5 min apart). markHandedOff makes alreadyHandedOff() short-circuit until the context
103
- // actually resets (<70% of where we fired), so it's exactly ONE baton per context window.
104
- markHandedOff(sessionId, usage.tokens);
103
+ // ARM, do not fire. This hook is PostToolUse: the only moment it can ever run is between two
104
+ // tool calls, i.e. mid-turn. Firing here produced a handoff written 36 seconds before the work
105
+ // it described was committed (2026-08-24), and the successor reported four finished things as
106
+ // still open. The Stop hook fires it at the next turn boundary, where the turn is complete.
107
+ //
108
+ // The window id and tty are captured HERE on purpose: this hook has the controlling tty and the
109
+ // detached worker does not, so the baton-close can still replace this exact window later.
110
+ const armed = readArm(sessionId);
111
+ const age = armed ? Date.now() - (Number(armed.ts) || 0) : 0;
112
+ if (armed && age < ARM_MAX_MS) {
113
+ process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% — baton already armed ${Math.round(age / 1000)}s ago, waiting for a turn boundary\n`);
114
+ return;
115
+ }
116
+ if (armed) {
117
+ // Backstop: a session that never reaches a Stop (parked, or looping without ending a turn)
118
+ // must still hand off rather than never. Fire it directly and say that is what happened.
119
+ process.stderr.write(`[trantor] baton armed ${Math.round(age / 60000)}m ago with no turn boundary — firing anyway\n`);
120
+ clearArm(sessionId);
121
+ if (spawnDebounced()) {
122
+ const child = spawn(process.execPath, [join(HERE, "handoff-now.mjs"), projectDir, sessionId, transcript, "context-warn", windowId, tty],
123
+ { detached: true, stdio: "ignore" });
124
+ child.unref();
125
+ markHandedOff(sessionId, usage.tokens);
126
+ }
127
+ return;
128
+ }
129
+ process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% of ${usage.window} — arming the baton for the next turn boundary (window ${windowId || "?"})\n`);
130
+ // Arming does NOT markHandedOff. That guard exists so a session parked above the warn line does
131
+ // not re-fire every tick (8 stacked handoffs ~5 min apart, once observed) — but it makes
132
+ // alreadyHandedOff() short-circuit this whole block, so marking at ARM time meant no later
133
+ // heartbeat could ever run the backstop and a session that reached no Stop stayed armed
134
+ // forever. It is marked where the baton actually fires: here on the backstop path, and in the
135
+ // Stop hook on the normal path. Re-arming every tick is prevented by the age check above.
136
+ armBaton(sessionId, { projectDir, transcript, reason: "context-warn", windowId, tty, tokens: usage.tokens });
105
137
  } catch {}
106
138
  }
107
139
 
@@ -10,7 +10,7 @@
10
10
  // session that loads the handoff. The heartbeat path lets us do that BEFORE the
11
11
  // wall when we know the window size. Both paths share a per-session guard so we
12
12
  // never write/spawn twice for the same context window.
13
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, fstatSync, closeSync } from "node:fs";
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, fstatSync, closeSync, rmSync } from "node:fs";
14
14
  import { join, basename, dirname } from "node:path";
15
15
  import { homedir, hostname } from "node:os";
16
16
  import { execSync, spawn } from "node:child_process";
@@ -116,6 +116,30 @@ function nowSec() { try { return Number(execSync("date +%s", { encoding: "utf8"
116
116
  // window up (or, before the 2026-06-21 fix, kill the original) while real in-flight agent work is
117
117
  // running. INCIDENT 2026-06-21: a 90% baton fired mid 2-agent build and the original session was
118
118
  // SIGKILLed mid-flight. Best-effort; returns false on any error.
119
+ // ---- ARMING: the baton waits for a turn boundary --------------------------------------------
120
+ // The heartbeat runs on PostToolUse, so the only moment it can ever fire is BETWEEN TWO TOOL CALLS
121
+ // — the middle of a turn. On 2026-08-24 that produced a handoff written 36 seconds before the work
122
+ // it described was committed, and the successor reported four finished things as still open.
123
+ // subagentsActive() was the only mid-flight guard and it only sees spawned sub-agents, not a
124
+ // session driving tool calls in its own loop.
125
+ //
126
+ // So the threshold ARMS and the Stop hook FIRES: at a Stop the turn is complete, which is the only
127
+ // point where a summary can describe something finished. One resolver for the marker path, used by
128
+ // both hooks, because two hooks disagreeing about a file path is its own recurring bug here.
129
+ export function armPath(sessionId) {
130
+ const safe = String(sessionId || "s").replace(/[^A-Za-z0-9_.-]/g, "_");
131
+ return join(process.env.AGENT_BUS_DIR || process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus"), `handoff-armed-${safe}.json`);
132
+ }
133
+ export function armBaton(sessionId, payload) {
134
+ try { writeFileSync(armPath(sessionId), JSON.stringify({ ts: Date.now(), ...payload })); return true; } catch { return false; }
135
+ }
136
+ export function readArm(sessionId) {
137
+ try { const p = armPath(sessionId); if (!existsSync(p)) return null; return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
138
+ }
139
+ export function clearArm(sessionId) {
140
+ try { rmSync(armPath(sessionId), { force: true }); } catch {}
141
+ }
142
+
119
143
  export function subagentsActive(transcriptPath, withinMs = 90_000) {
120
144
  try {
121
145
  if (!transcriptPath) return false;
@@ -22,11 +22,16 @@
22
22
  // delivered and then letting the stop through would hide it from the waker too — a silent hole.
23
23
  // * Any error, or a hub that is down -> allow the stop. Never trap a session because of us.
24
24
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
25
- import { join } from "node:path";
25
+ import { join, dirname } from "node:path";
26
+ import { spawn } from "node:child_process";
27
+ import { fileURLToPath } from "node:url";
26
28
  import { homedir } from "node:os";
27
29
  import { resolveProject, hostId } from "../lib/project.mjs";
28
30
  import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
29
31
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
32
+ import { readArm, clearArm, markHandedOff } from "./lib/handoff.mjs";
33
+
34
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
35
 
31
36
  const FETCH_TIMEOUT_MS = Number(process.env.RELAY_STOP_TIMEOUT_MS || 1500);
32
37
 
@@ -103,6 +108,27 @@ async function main() {
103
108
  if (process.env.RELAY_STOP_INBOX === "0") return allow();
104
109
 
105
110
  const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
111
+
112
+ // A Stop IS the turn boundary. If the heartbeat armed a baton while this session was mid-turn,
113
+ // this is the first honest moment to fire it: the turn is complete, so the summary describes
114
+ // finished work rather than a session thirty seconds from its own conclusions. Fired detached and
115
+ // never awaited, and the arming is cleared FIRST so a crash in the worker cannot re-fire it on
116
+ // every subsequent Stop.
117
+ try {
118
+ const armed = readArm(input.session_id || "");
119
+ if (armed) {
120
+ clearArm(input.session_id || "");
121
+ const kid = spawn(process.execPath, [join(HERE, "handoff-now.mjs"),
122
+ armed.projectDir || projectDir, String(input.session_id || ""), armed.transcript || "",
123
+ armed.reason || "context-warn", armed.windowId || "", armed.tty || ""],
124
+ { detached: true, stdio: "ignore" });
125
+ kid.unref();
126
+ // Mark it here, on the path that actually fired, so a session parked above the warn line
127
+ // cannot re-arm and re-fire every tick.
128
+ try { markHandedOff(String(input.session_id || ""), Number(armed.tokens) || 0); } catch {}
129
+ process.stderr.write("[trantor] turn boundary reached — firing the armed baton\n");
130
+ }
131
+ } catch {}
106
132
  // Mirror the other hooks: a home-directory session isn't project work and isn't on the bus.
107
133
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return allow();
108
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.96",
3
+ "version": "0.17.97",
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-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-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-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": [