trantor 0.17.96 → 0.17.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.
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/heartbeat.mjs +45 -13
- package/hooks/lib/handoff.mjs +35 -3
- package/hooks/stop-inbox.mjs +27 -1
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.98",
|
|
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": {
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -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;
|
|
@@ -412,9 +436,17 @@ export function maybeSpawn(projectDir, conf = readConfig()) {
|
|
|
412
436
|
export const RECAP_CMD = "claude 'Recap the handoff you just took over — what was the previous session doing, and where do we continue? Then wait for me.'";
|
|
413
437
|
|
|
414
438
|
// Spawn a fresh self-announcing session WITHOUT the dialog (manual handoff — the user already decided).
|
|
439
|
+
// ONE suppression check for every path that can open a terminal window. There were two names for
|
|
440
|
+
// this — TRANTOR_NO_HANDOFF_SPAWN here and TRANTOR_NO_BATON_SPAWN on spawnBaton — and a drill that
|
|
441
|
+
// set the wrong one opened eight live sessions in deleted temp directories, twice. A guard with two
|
|
442
|
+
// names is a guard you can miss, so both are honoured wherever a window is opened.
|
|
443
|
+
export function spawnSuppressed() {
|
|
444
|
+
return process.env.TRANTOR_NO_HANDOFF_SPAWN === "1" || process.env.TRANTOR_NO_BATON_SPAWN === "1";
|
|
445
|
+
}
|
|
446
|
+
|
|
415
447
|
export function spawnFresh(projectDir) {
|
|
416
448
|
try {
|
|
417
|
-
if (process.platform !== "darwin" ||
|
|
449
|
+
if (process.platform !== "darwin" || spawnSuppressed()) return false;
|
|
418
450
|
const script = join(HERE, "..", "..", "bin", "open-session.sh");
|
|
419
451
|
if (!existsSync(script)) return false;
|
|
420
452
|
const child = spawn("/bin/bash", [script, projectDir, RECAP_CMD], { detached: true, stdio: "ignore" });
|
|
@@ -458,7 +490,7 @@ export function spawnBaton({ projectDir, handoffFile, conf = readConfig(),
|
|
|
458
490
|
// then deleted, each parked on a "do you trust this folder?" prompt. Five of them were found by
|
|
459
491
|
// the operator on 2026-08-24. A code path that spawns windows needs an off switch, or it cannot
|
|
460
492
|
// be tested honestly and someone will fake one that does not exist.
|
|
461
|
-
if (
|
|
493
|
+
if (spawnSuppressed() || conf.batonSpawn === false) {
|
|
462
494
|
return { spawned: false, armed: false, windowId: "", suppressed: true };
|
|
463
495
|
}
|
|
464
496
|
const { windowId, tty } = _resolveWindow(); // original window FIRST, while it's still frontmost
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.17.98",
|
|
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": [
|