trantor 0.18.43 → 0.18.47
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/bin/balances.mjs +8 -2
- package/bin/baton.mjs +29 -2
- package/bin/cli.mjs +1 -0
- package/bin/provider.mjs +17 -3
- package/hooks/ask-sidecar.mjs +103 -0
- package/hooks/githooks/pre-push +4 -3
- package/hooks/heartbeat.mjs +2 -2
- package/hooks/hooks.json +49 -0
- package/hooks/lib/handoff.mjs +72 -2
- package/hooks/prompt-focus.mjs +3 -0
- package/hooks/stop-inbox.mjs +9 -2
- package/hub/routes/admin.mjs +8 -8
- package/lib/providers.mjs +15 -1
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.47",
|
|
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/balances.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: config.json is user-editable external input; thresholds validates its optional number/object fields at that I/O boundary. */
|
|
2
3
|
// trantor balances — show how much credit is left on each prepaid provider (DeepSeek, Kimi, OpenRouter…)
|
|
3
4
|
// so you can refill BEFORE a build stalls. Reads keys from the environment, queries each provider's
|
|
4
5
|
// balance API, prints them, and pushes the snapshot to the hub so the dashboard + other sessions see it.
|
|
@@ -8,6 +9,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
8
9
|
import { join } from "node:path";
|
|
9
10
|
import { homedir } from "node:os";
|
|
10
11
|
import { fetchBalances, isLow, fmtBalance, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../lib/balances.mjs";
|
|
12
|
+
import { detectedCliBalanceRows } from "../lib/providers.mjs";
|
|
11
13
|
import { loadProfile } from "./profile.mjs";
|
|
12
14
|
import { resolveKeys } from "../lib/provider-keys.mjs";
|
|
13
15
|
|
|
@@ -15,7 +17,8 @@ const args = process.argv.slice(2);
|
|
|
15
17
|
const asJson = args.includes("--json");
|
|
16
18
|
const noPush = args.includes("--no-push");
|
|
17
19
|
|
|
18
|
-
//
|
|
20
|
+
// API-key providers stay profile-scoped so ambient keys are never scraped. Claude and Codex are
|
|
21
|
+
// machine CLI logins: the registry admits them from binary + credential + live-probe detection.
|
|
19
22
|
const configured = Object.keys(loadProfile().providers || {});
|
|
20
23
|
|
|
21
24
|
// Signed via the shared client (2026-07-31, agent-UX audit): unsigned POST rejected under enforce.
|
|
@@ -26,7 +29,10 @@ function thresholds() {
|
|
|
26
29
|
return DEFAULT_LOW;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
const
|
|
32
|
+
const env = resolveKeys(process.env);
|
|
33
|
+
const detected = await detectedCliBalanceRows({ env });
|
|
34
|
+
const profileScoped = await fetchBalances(env, { only: configured.filter((provider) => provider !== "claude" && provider !== "codex") });
|
|
35
|
+
const balances = [...detected, ...profileScoped];
|
|
30
36
|
const low = thresholds();
|
|
31
37
|
|
|
32
38
|
// push the snapshot to the hub (best-effort) so the dashboard + warning line can use it.
|
package/bin/baton.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { join, basename, dirname } from "node:path";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { writeHandoff, spawnBaton, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
|
|
11
|
+
import { writeHandoff, spawnBaton, resolveHandoffSurface, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs } from "../hooks/lib/handoff.mjs";
|
|
12
12
|
|
|
13
13
|
// #6074: the skill path (write-handoff.mjs) and this CLI path must share ONE resolution of which
|
|
14
14
|
// project this is and where the session lives. Both call resolveHandoffSurface; the name comes
|
|
@@ -64,7 +64,34 @@ function autoBaton() {
|
|
|
64
64
|
// The transcript's filename IS the writing session's id — record it, or an orchestrator-thread
|
|
65
65
|
// handoff carries no writer and the baton-hold + map-follow logic in sessionstart.mjs can't fire.
|
|
66
66
|
const sessionId = transcript ? basename(transcript, ".jsonl") : "";
|
|
67
|
-
|
|
67
|
+
// #6528: WHO pulled the trigger. A TTY stdin means a human typed `trantor handoff` at a prompt;
|
|
68
|
+
// the app chain (lib.rs handoff_now) and hooks spawn this binary with piped stdio. The operator's
|
|
69
|
+
// own typed command keeps the storm-guard bypass (force:true — "manual = intentional"); every
|
|
70
|
+
// invoked path goes through the boundary gate and the hub's storm guard like any auto handoff.
|
|
71
|
+
// --reason rides through from the app (`--reason clicked|countdown|unattended`) so the RECORD
|
|
72
|
+
// finally names the real trigger instead of laundering every banner fire into "manual-cli".
|
|
73
|
+
const reasonArg = (() => {
|
|
74
|
+
const i = process.argv.indexOf("--reason");
|
|
75
|
+
const v = i >= 0 ? String(process.argv[i + 1] || "").trim() : "";
|
|
76
|
+
return v && !v.startsWith("--") ? v : "";
|
|
77
|
+
})();
|
|
78
|
+
const operatorTyped = !!process.stdin.isTTY;
|
|
79
|
+
const trigger = reasonArg || "manual-cli";
|
|
80
|
+
// --force: the hard-cap leg (#6528). The app's boundary wait timed out (or an operator typed it
|
|
81
|
+
// mid-turn on purpose) — write NOW, gate or no gate, and say that is what happened. Without it,
|
|
82
|
+
// a turn still in flight ARMS instead of writing: no record, no spawn, and the session's own
|
|
83
|
+
// Stop hook fires the baton at the boundary, where the summary describes finished work.
|
|
84
|
+
const force = process.argv.includes("--force");
|
|
85
|
+
if (!force && turnInFlight(transcript)) {
|
|
86
|
+
armBaton(sessionId, {
|
|
87
|
+
projectDir: cwd,
|
|
88
|
+
transcript, reason: trigger, windowId: "", tty: controllingTty(),
|
|
89
|
+
tokens: contextUsage(transcript)?.tokens || 0,
|
|
90
|
+
});
|
|
91
|
+
console.log(`⏸ handoff armed — it fires when this turn finishes (hard cap ${Math.round(armMaxMs() / 60000)}m: the next tool boundary fires it). No record written yet.`);
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
const { file } = writeHandoff({ projectDir: cwd, sessionId, transcript, trigger, force, projectName: project }); // operator-typed = intentional, bypass the storm guard
|
|
68
95
|
console.log(`📋 handoff saved for ${project}: ${file}`);
|
|
69
96
|
// --write-only: the in-app flow (#5509). The app ends the pane's session itself and reopens it
|
|
70
97
|
// through `trantor open`, which claims this handoff — a Terminal window here would be exactly the
|
package/bin/cli.mjs
CHANGED
|
@@ -30,6 +30,7 @@ switch (cmd) {
|
|
|
30
30
|
case "open": runCrew(); break;
|
|
31
31
|
case "herdr": spawn(process.execPath, [join(ROOT, "bin/herdr-agent.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
32
32
|
case "autonomy": spawn(process.execPath, [join(ROOT, "bin/autonomy.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
33
|
+
case "agent-settings": run("bin/agent-settings.mjs"); break;
|
|
33
34
|
case "adopt": spawn(process.execPath, [join(ROOT, "bin/adopt.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
34
35
|
case "integrate": spawn(process.execPath, [join(ROOT, "bin/integrate.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
35
36
|
case "down": runCrew(); break;
|
package/bin/provider.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, appendFi
|
|
|
19
19
|
import { join, dirname } from "node:path";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { execSync, spawnSync } from "node:child_process";
|
|
22
|
-
import { pathToFileURL } from "node:url";
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
23
|
import { buildRoster, loadWorld } from "./advise.mjs";
|
|
24
24
|
import { providerStatus, providerVerify, PROVIDERS } from "../lib/providers.mjs";
|
|
25
25
|
|
|
@@ -31,6 +31,7 @@ const C = { dim: "\x1b[2m", grn: "\x1b[32m", red: "\x1b[31m", yel: "\x1b[33m", g
|
|
|
31
31
|
const envKeyName = (p) => `${String(p).toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
32
32
|
|
|
33
33
|
const OC_CONFIG = join(H, ".config", "opencode", "opencode.json");
|
|
34
|
+
const PROFILE_BIN = join(dirname(fileURLToPath(import.meta.url)), "profile.mjs");
|
|
34
35
|
// Wire a CUSTOM OpenAI-compatible provider into opencode.json (matching opencode's schema +
|
|
35
36
|
// the existing providers' `options.apiKey` style). Merges, never clobbers other providers.
|
|
36
37
|
// `configPath` is injectable so it can be unit-tested against a temp file.
|
|
@@ -152,7 +153,8 @@ function addProvider(name, opts) {
|
|
|
152
153
|
|
|
153
154
|
// 2) declare the plan in the quota profile (drives the Advisor's tier/cost reasoning)
|
|
154
155
|
try {
|
|
155
|
-
|
|
156
|
+
const declared = spawnSync(process.execPath, [PROFILE_BIN, "set", `${provider}=${plan}`], { stdio: "ignore" });
|
|
157
|
+
if (declared.error || declared.status !== 0) throw declared.error || new Error(`profile exited ${declared.status}`);
|
|
156
158
|
console.log(`${C.grn}✓${C.off} profile: ${provider}=${plan}`);
|
|
157
159
|
} catch (e) { console.log(`${C.yel}⚠${C.off} could not set profile (run: trantor profile set ${provider}=${plan})`); }
|
|
158
160
|
|
|
@@ -242,10 +244,22 @@ function loginProvider(name) {
|
|
|
242
244
|
}
|
|
243
245
|
console.log(`${C.dim}running:${C.off} ${p.loginRun.join(" ")} ${C.dim}(the CLI's own login — sign in there)${C.off}`);
|
|
244
246
|
const r = spawnSync(p.loginRun[0], p.loginRun.slice(1), { stdio: "inherit" });
|
|
245
|
-
if (r.error ||
|
|
247
|
+
if (r.error || r.status !== 0) {
|
|
246
248
|
console.error(`\n${p.loginRun[0]} exited ${r.status ?? "?"} — install it first, then re-run: trantor provider login ${p.provider}`);
|
|
247
249
|
process.exit(1);
|
|
248
250
|
}
|
|
251
|
+
const profile = read(join(H, ".agent-bus", "profile.json"), { providers: {} });
|
|
252
|
+
const plan = profile.providers?.[p.provider]?.plan || "subscription";
|
|
253
|
+
const declared = spawnSync(process.execPath, [PROFILE_BIN, "set", `${p.provider}=${plan}`], {
|
|
254
|
+
encoding: "utf8",
|
|
255
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
256
|
+
});
|
|
257
|
+
if (declared.error || declared.status !== 0) {
|
|
258
|
+
const detail = String(declared.stderr || declared.error?.message || "").trim();
|
|
259
|
+
console.error(`\nlogin succeeded, but ${p.provider} could not be restored to the quota profile${detail ? ` — ${detail}` : ""}`);
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
console.log(`${C.grn}✓${C.off} profile: ${p.provider}=${plan}`);
|
|
249
263
|
console.log(`\n${C.dim}re-check it live:${C.off} trantor provider status`);
|
|
250
264
|
}
|
|
251
265
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Export a live AskUserQuestion before Claude's transcript flushes it (#6533).
|
|
3
|
+
import {
|
|
4
|
+
mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync,
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { sessionContext } from "./lib/api.mjs";
|
|
9
|
+
|
|
10
|
+
function readStdin() {
|
|
11
|
+
return new Promise(res => {
|
|
12
|
+
let d = ""; process.stdin.setEncoding("utf8");
|
|
13
|
+
process.stdin.on("data", c => { d += c; });
|
|
14
|
+
process.stdin.on("end", () => res(d));
|
|
15
|
+
setTimeout(() => res(d), 400);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
20
|
+
|
|
21
|
+
function sidecarPath(sessionId) {
|
|
22
|
+
const sid = String(sessionId ?? "").trim();
|
|
23
|
+
if (!sid || sid === "." || sid === ".." || !/^[A-Za-z0-9._-]+$/.test(sid)) return null;
|
|
24
|
+
return join(busDir(), "asks", `${sid}.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toolUseId(input) {
|
|
28
|
+
const id = input?.tool_use_id;
|
|
29
|
+
return id === undefined || id === null || String(id).trim() === "" ? null : String(id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function existingOpen(path, sessionId) {
|
|
33
|
+
try {
|
|
34
|
+
const stored = JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
return String(stored.session_id ?? "") === String(sessionId) ? stored : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sameOpen(left, right) {
|
|
42
|
+
return left.session_id === right.session_id && left.project === right.project &&
|
|
43
|
+
left.cwd === right.cwd && (left.tool_use_id ?? null) === right.tool_use_id &&
|
|
44
|
+
left.event === right.event && (left.visible_ts ?? null) === right.visible_ts &&
|
|
45
|
+
JSON.stringify(left.questions) === JSON.stringify(right.questions);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeOpen(input, path) {
|
|
49
|
+
if (String(input.tool_name ?? "") !== "AskUserQuestion") return;
|
|
50
|
+
const questions = input.tool_input?.questions;
|
|
51
|
+
if (!Array.isArray(questions)) return;
|
|
52
|
+
const cwd = String(input.cwd ?? "");
|
|
53
|
+
const ctx = sessionContext(cwd);
|
|
54
|
+
const stored = existingOpen(path, input.session_id);
|
|
55
|
+
const incomingId = toolUseId(input);
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const permissionVisible = String(input.hook_event_name ?? "") === "PermissionRequest";
|
|
58
|
+
const visibleTs = stored?.visible_ts ?? (permissionVisible ? now : null);
|
|
59
|
+
const payload = {
|
|
60
|
+
session_id: String(input.session_id),
|
|
61
|
+
project: ctx.project,
|
|
62
|
+
cwd,
|
|
63
|
+
tool_use_id: incomingId ?? stored?.tool_use_id ?? null,
|
|
64
|
+
questions,
|
|
65
|
+
event: visibleTs === null ? "PreToolUse" : "PermissionRequest",
|
|
66
|
+
visible_ts: visibleTs,
|
|
67
|
+
ts: stored?.ts ?? now,
|
|
68
|
+
};
|
|
69
|
+
if (stored && sameOpen(stored, payload)) return;
|
|
70
|
+
const dir = join(busDir(), "asks");
|
|
71
|
+
mkdirSync(dir, { recursive: true });
|
|
72
|
+
const tmp = join(dir, `.${String(input.session_id)}.${process.pid}.${Date.now()}.tmp`);
|
|
73
|
+
try {
|
|
74
|
+
writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
|
|
75
|
+
renameSync(tmp, path);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
try { unlinkSync(tmp); } catch {}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function closeTool(input, path) {
|
|
83
|
+
const stored = JSON.parse(readFileSync(path, "utf8"));
|
|
84
|
+
const storedId = stored.tool_use_id ?? null;
|
|
85
|
+
if (storedId === null || storedId === toolUseId(input)) unlinkSync(path);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const raw = await readStdin();
|
|
90
|
+
const input = JSON.parse(raw || "{}");
|
|
91
|
+
const path = sidecarPath(input?.session_id);
|
|
92
|
+
if (path) {
|
|
93
|
+
const event = String(input.hook_event_name ?? "");
|
|
94
|
+
if (event === "PreToolUse" || event === "PermissionRequest") writeOpen(input, path);
|
|
95
|
+
else if (event === "PostToolUse" || event === "PostToolUseFailure") closeTool(input, path);
|
|
96
|
+
else if (event === "Stop") {
|
|
97
|
+
try { unlinkSync(path); } catch {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
|
|
102
|
+
// Informational state only: never approve, deny, answer, or inject context.
|
|
103
|
+
process.stdout.write("{}");
|
package/hooks/githooks/pre-push
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# #6446 (BUILD-DOCTRINE.md rule 2, "red blocks merge"): branch protection gates PR merges, but
|
|
3
3
|
# the orchestrator's merges are LOCAL pushes to main, which a required check cannot gate. This
|
|
4
|
-
# pre-push hook runs the fast subset (slop-gate +
|
|
5
|
-
#
|
|
4
|
+
# pre-push hook runs the fast subset (slop-gate + the hermetic sessionstart drill) and refuses
|
|
5
|
+
# the push on red. The full suite is `npm test` → test/run.mjs (#6447) and runs in CI on every
|
|
6
|
+
# push. Deliberate bypass: git push --no-verify.
|
|
6
7
|
set -u
|
|
7
8
|
ROOT="$(git rev-parse --show-toplevel)" || exit 1
|
|
8
9
|
cd "$ROOT" || exit 1
|
|
@@ -21,7 +22,7 @@ while read -r _ local_ref _ _; do
|
|
|
21
22
|
echo "trantor pre-push: slop-gate RED — push refused. Fix it, or bypass with --no-verify (CI still gates the PR)." >&2
|
|
22
23
|
exit 1
|
|
23
24
|
fi
|
|
24
|
-
if ! node test.mjs; then
|
|
25
|
+
if ! node test/hooks/test.mjs; then
|
|
25
26
|
echo "trantor pre-push: test.mjs RED — push refused. Fix it, or bypass with --no-verify (CI still gates the PR)." >&2
|
|
26
27
|
exit 1
|
|
27
28
|
fi
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -18,14 +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 { armBaton, readArm, clearArm, 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, armMaxMs } 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 =
|
|
28
|
+
const ARM_MAX_MS = armMaxMs(); // #6528: one source for the hard cap (shared with bin/baton.mjs's printed promise)
|
|
29
29
|
const INFLIGHT_MS = 5 * 60 * 1000;
|
|
30
30
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
31
|
|
package/hooks/hooks.json
CHANGED
|
@@ -45,6 +45,26 @@
|
|
|
45
45
|
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/file-claim.mjs"
|
|
46
46
|
}
|
|
47
47
|
]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"matcher": "AskUserQuestion",
|
|
51
|
+
"hooks": [
|
|
52
|
+
{
|
|
53
|
+
"type": "command",
|
|
54
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"PermissionRequest": [
|
|
60
|
+
{
|
|
61
|
+
"matcher": "AskUserQuestion",
|
|
62
|
+
"hooks": [
|
|
63
|
+
{
|
|
64
|
+
"type": "command",
|
|
65
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
|
|
66
|
+
}
|
|
67
|
+
]
|
|
48
68
|
}
|
|
49
69
|
],
|
|
50
70
|
"SubagentStart": [
|
|
@@ -85,6 +105,26 @@
|
|
|
85
105
|
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/todo-sync.mjs"
|
|
86
106
|
}
|
|
87
107
|
]
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"matcher": "AskUserQuestion",
|
|
111
|
+
"hooks": [
|
|
112
|
+
{
|
|
113
|
+
"type": "command",
|
|
114
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
"PostToolUseFailure": [
|
|
120
|
+
{
|
|
121
|
+
"matcher": "AskUserQuestion",
|
|
122
|
+
"hooks": [
|
|
123
|
+
{
|
|
124
|
+
"type": "command",
|
|
125
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
|
|
126
|
+
}
|
|
127
|
+
]
|
|
88
128
|
}
|
|
89
129
|
],
|
|
90
130
|
"PreCompact": [
|
|
@@ -121,6 +161,15 @@
|
|
|
121
161
|
}
|
|
122
162
|
],
|
|
123
163
|
"Stop": [
|
|
164
|
+
{
|
|
165
|
+
"matcher": "",
|
|
166
|
+
"hooks": [
|
|
167
|
+
{
|
|
168
|
+
"type": "command",
|
|
169
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
},
|
|
124
173
|
{
|
|
125
174
|
"matcher": "",
|
|
126
175
|
"hooks": [
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -163,8 +163,25 @@ export function armPath(sessionId) {
|
|
|
163
163
|
const safe = String(sessionId || "s").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
164
164
|
return join(process.env.AGENT_BUS_DIR || process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus"), `handoff-armed-${safe}.json`);
|
|
165
165
|
}
|
|
166
|
+
// The hard cap on an arm (#6528): a session that never reaches a Stop must still hand off —
|
|
167
|
+
// the heartbeat fires at the next tool boundary once the arm is this old. One source for the
|
|
168
|
+
// number, because the CLI (bin/baton.mjs) prints it in its armed message and the heartbeat
|
|
169
|
+
// enforces it; two copies would drift and the printed promise would be a lie.
|
|
170
|
+
export function armMaxMs() {
|
|
171
|
+
const n = Number(process.env.TRANTOR_BATON_ARM_MAX_MS);
|
|
172
|
+
return Number.isFinite(n) && n > 0 ? n : 15 * 60 * 1000;
|
|
173
|
+
}
|
|
166
174
|
export function armBaton(sessionId, payload) {
|
|
167
|
-
try {
|
|
175
|
+
try {
|
|
176
|
+
// Re-arming must NOT refresh the timestamp (#6528): the banner can re-fire the request
|
|
177
|
+
// every few seconds, and a slid-forward ts would starve the hard cap forever — an arm
|
|
178
|
+
// that is always brand-new never ages into the heartbeat's fire-anyway backstop. The
|
|
179
|
+
// FIRST arm's ts is the arm's age; later writes only refresh the payload.
|
|
180
|
+
const prior = readArm(sessionId);
|
|
181
|
+
const ts = prior?.ts || Date.now();
|
|
182
|
+
writeFileSync(armPath(sessionId), JSON.stringify({ ts, ...payload }));
|
|
183
|
+
return true;
|
|
184
|
+
} catch { return false; }
|
|
168
185
|
}
|
|
169
186
|
export function readArm(sessionId) {
|
|
170
187
|
try { const p = armPath(sessionId); if (!existsSync(p)) return null; return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
|
|
@@ -194,6 +211,59 @@ export function subagentsActive(transcriptPath, withinMs = 90_000) {
|
|
|
194
211
|
} catch { return false; }
|
|
195
212
|
}
|
|
196
213
|
|
|
214
|
+
// ---- #6528: THE ONE GATE — is this session's turn still in flight? --------------------------
|
|
215
|
+
// Two signals, both read from artifacts the session itself already writes:
|
|
216
|
+
// 1. subagentsActive() — a spawned sub-agent wrote its transcript recently. The 90s mtime
|
|
217
|
+
// window is a false-idle risk (a sub-agent in a long model stretch writes nothing for
|
|
218
|
+
// minutes — that is exactly how orca-onboarding-map went unseen on #6528), but widening
|
|
219
|
+
// it only ever DEFERS a handoff, never fires one early — the safe direction.
|
|
220
|
+
// 2. the transcript TAIL — the last real row tells where the turn stands. A user row
|
|
221
|
+
// carrying tool_result means the model is about to continue (mid-turn). An assistant
|
|
222
|
+
// row carrying tool_use means a result is still owed (mid-turn). Only an assistant row
|
|
223
|
+
// that is plain text (the turn's closing words) reads as idle — the same state the Stop
|
|
224
|
+
// hook fires on.
|
|
225
|
+
// Every path that can WRITE+SPAWN a handoff (heartbeat backstop, Stop hook, `trantor handoff`)
|
|
226
|
+
// asks this before firing; only an operator's own typed command or the explicit hard-cap leg
|
|
227
|
+
// (--force) may bypass it.
|
|
228
|
+
const TAIL_BYTES = 262_144;
|
|
229
|
+
function transcriptTailRows(transcriptPath) {
|
|
230
|
+
const fd = openSync(transcriptPath, "r");
|
|
231
|
+
try {
|
|
232
|
+
const size = fstatSync(fd).size;
|
|
233
|
+
const want = Math.min(size, TAIL_BYTES);
|
|
234
|
+
const b = Buffer.alloc(want);
|
|
235
|
+
readSync(fd, b, 0, want, size - want);
|
|
236
|
+
// Drop the first (possibly partial) line, then parse what follows.
|
|
237
|
+
return b.toString("utf8").split("\n").slice(1).filter(Boolean);
|
|
238
|
+
} finally { closeSync(fd); }
|
|
239
|
+
}
|
|
240
|
+
export function lastRowMidTurn(transcriptPath) {
|
|
241
|
+
try {
|
|
242
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return false;
|
|
243
|
+
const rows = transcriptTailRows(transcriptPath);
|
|
244
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
245
|
+
let r; try { r = JSON.parse(rows[i]); } catch { continue; }
|
|
246
|
+
if (r?.type !== "assistant" && r?.type !== "user") continue; // metadata rows say nothing
|
|
247
|
+
const c = r?.message?.content;
|
|
248
|
+
if (r.type === "assistant") {
|
|
249
|
+
const blocks = Array.isArray(c) ? c : [];
|
|
250
|
+
if (blocks.some(b => b?.type === "tool_use")) return true; // a result is still owed
|
|
251
|
+
return false; // text-only → turn said its piece
|
|
252
|
+
}
|
|
253
|
+
// user row: #6528 follow-up — a trailing user row of ANY kind means in flight. A
|
|
254
|
+
// tool_result is the model mid-cycle, and a PLAIN prompt is the model WORKING on that
|
|
255
|
+
// prompt: Claude Code does not flush the assistant turn until it ends, so the assistant
|
|
256
|
+
// row's absence is not idle evidence. The only idle evidence is the text-only assistant
|
|
257
|
+
// row above, or the Stop hook itself.
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
} catch { return false; }
|
|
262
|
+
}
|
|
263
|
+
export function turnInFlight(transcriptPath) {
|
|
264
|
+
return subagentsActive(transcriptPath) || lastRowMidTurn(transcriptPath);
|
|
265
|
+
}
|
|
266
|
+
|
|
197
267
|
// ---- whole-session summary --------------------------------------------------
|
|
198
268
|
function collectTurns(transcriptPath) {
|
|
199
269
|
const rows = readFileSync(transcriptPath, "utf8").split("\n").filter(Boolean)
|
|
@@ -275,7 +345,7 @@ export function buildSummary(transcriptPath) {
|
|
|
275
345
|
let convo = "";
|
|
276
346
|
try { convo = digest(collectTurns(transcriptPath)); } catch { convo = ""; }
|
|
277
347
|
if (!convo) return "*(transcript unreadable)*";
|
|
278
|
-
const sys = "You are writing a SESSION HANDOFF so a fresh Claude Code session can take over without losing context. The text spans an entire (possibly multi-hour) session: opening turns, an even sample of the middle, and the recent tail. Produce a concise but COMPLETE markdown handoff with these sections: TASK (what we're doing + the goal), STATE (done / in-progress), KEY DECISIONS, OPEN THREADS & NEXT STEPS (concrete actions), KEY FILES & locations (exact paths). Be specific. Cover the whole arc, not just the end. Do not pad.";
|
|
348
|
+
const sys = "You are writing a SESSION HANDOFF so a fresh Claude Code session can take over without losing context. The text spans an entire (possibly multi-hour) session: opening turns, an even sample of the middle, and the recent tail. Produce a concise but COMPLETE markdown handoff with these sections: TASK (what we're doing + the goal), STATE (done / in-progress), KEY DECISIONS, OPEN THREADS & NEXT STEPS (concrete actions), KEY FILES & locations (exact paths). Be specific. Cover the whole arc, not just the end. The finished handoff must fit ~3500 characters — anything longer is capped with an elision marker and the elided middle (usually STATE) is exactly what the successor needed (#6528), so compress the arc, never drop a section. Do not pad.";
|
|
279
349
|
// Cut the raw tail on a TURN boundary. A blind slice(-12000) opens mid-sentence, which is how the
|
|
280
350
|
// 2026-08-24 handoff began, and a successor cannot tell a truncated thought from a complete one.
|
|
281
351
|
const tail = (n) => {
|
package/hooks/prompt-focus.mjs
CHANGED
|
@@ -69,6 +69,9 @@ try {
|
|
|
69
69
|
const trimmed = prompt.replace(/\s+/g, " ").trim();
|
|
70
70
|
// skip empties, tiny continuations, and pure acks — they're not a new focus
|
|
71
71
|
if (!trimmed || trimmed.length < 12 || ACK.test(trimmed)) { emitAndExit(); }
|
|
72
|
+
// The Accounts ask drill launches a real Claude session, so its scripted prompt traverses this
|
|
73
|
+
// hook just like operator work. It is harness traffic, though, and must never become a focus card.
|
|
74
|
+
if (/\bTRANTOR ASK DRILL\b/.test(trimmed)) { emitAndExit(); }
|
|
72
75
|
// HARNESS-INJECTED prompts are not a human's focus. Task notifications, hook system-reminders and
|
|
73
76
|
// protocol frames arrive through the same UserPromptSubmit channel, and carding one titled a board
|
|
74
77
|
// card "<task-notification> <task-id>bavlqfmzq</task-id>…" — pure noise a human cannot read.
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
|
|
|
29
29
|
import { resolveProject, hostId, handoffDir, busDir } from "../lib/project.mjs";
|
|
30
30
|
import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
|
|
31
31
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
32
|
-
import { readArm, clearArm, markHandedOff, appendHandoffState } from "./lib/handoff.mjs";
|
|
32
|
+
import { readArm, clearArm, markHandedOff, appendHandoffState, subagentsActive } from "./lib/handoff.mjs";
|
|
33
33
|
|
|
34
34
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
35
35
|
|
|
@@ -169,9 +169,16 @@ async function main() {
|
|
|
169
169
|
// finished work rather than a session thirty seconds from its own conclusions. Fired detached and
|
|
170
170
|
// never awaited, and the arming is cleared FIRST so a crash in the worker cannot re-fire it on
|
|
171
171
|
// every subsequent Stop.
|
|
172
|
+
// #6528: a boundary is only a boundary when NOTHING is still running. CC's Stop fires while a
|
|
173
|
+
// backgrounded sub-agent (fork, teammate, workflow leg) can still be mid-flight — firing then
|
|
174
|
+
// hands the baton to a successor that yanks the work's parent out from under it. The arm STAYS:
|
|
175
|
+
// the next Stop fires it, and the heartbeat's hard cap (armMaxMs, next tool boundary) keeps a
|
|
176
|
+
// never-idle session from being armed forever.
|
|
172
177
|
try {
|
|
173
178
|
const armed = readArm(input.session_id || "");
|
|
174
|
-
if (armed) {
|
|
179
|
+
if (armed && armed.transcript && subagentsActive(armed.transcript)) {
|
|
180
|
+
process.stderr.write("[trantor] turn boundary reached but sub-agents are still active — the armed baton stays armed for the next boundary\n");
|
|
181
|
+
} else if (armed) {
|
|
175
182
|
clearArm(input.session_id || "");
|
|
176
183
|
const kid = spawn(process.execPath, [join(HERE, "handoff-now.mjs"),
|
|
177
184
|
armed.projectDir || projectDir, String(input.session_id || ""), armed.transcript || "",
|
package/hub/routes/admin.mjs
CHANGED
|
@@ -335,23 +335,23 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
335
335
|
const canonP = p => ALIAS[p] || p;
|
|
336
336
|
let prof = {}; try { prof = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "profile.json"), "utf8")).providers || {}; } catch {}
|
|
337
337
|
const profByCanon = {}; for (const [p, v] of Object.entries(prof)) profByCanon[canonP(p)] = v;
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
338
|
+
const detectedCli = new Set(["claude", "codex"]);
|
|
339
|
+
// API-key rows remain profile-scoped so a stray ambient key never appears. Claude and Codex
|
|
340
|
+
// instead arrive only after the client registry detects their binary, auth artifact and probe;
|
|
341
|
+
// a missing quota declaration must not hide those machine-login rows from the bottom bar.
|
|
342
342
|
// a prepaid entry that ERRORED but whose provider is a subscription per profile is really a
|
|
343
343
|
// subscription (some plan keys have no balance endpoint → the 401 is expected, not a problem).
|
|
344
344
|
const isSub = (t) => !!t && t !== "api"; // capped-sub / high-sub → a subscription (nothing to refill)
|
|
345
|
-
const entries = (state.balances?.entries || []).filter(e => profByCanon[canonP(e.provider)]).map(e => {
|
|
345
|
+
const entries = (state.balances?.entries || []).filter(e => detectedCli.has(e.provider) || profByCanon[canonP(e.provider)]).map(e => {
|
|
346
346
|
const pv = profByCanon[canonP(e.provider)];
|
|
347
347
|
if (!e.ok && isSub(pv?.tier)) return { provider: e.provider, label: e.label, kind: "subscription", plan: pv.plan, ok: true, remaining: null, low: false };
|
|
348
348
|
return { ...e, low: lowOf(e) };
|
|
349
349
|
});
|
|
350
|
-
//
|
|
351
|
-
//
|
|
350
|
+
// List configured non-CLI subscriptions not already fetched. Claude/Codex may only come from
|
|
351
|
+
// live registry detection above; a stale profile declaration cannot manufacture either row.
|
|
352
352
|
const known = new Set(entries.map(e => canonP(e.provider)));
|
|
353
353
|
const subs = Object.entries(prof)
|
|
354
|
-
.filter(([p, v]) => isSub(v?.tier) && !known.has(canonP(p)))
|
|
354
|
+
.filter(([p, v]) => !detectedCli.has(p) && isSub(v?.tier) && !known.has(canonP(p)))
|
|
355
355
|
.map(([p, v]) => ({ provider: p, label: p, kind: "subscription", plan: v.plan, ok: true, remaining: null, low: false }));
|
|
356
356
|
return json(res, 200, { ts: state.balances?.ts || 0, by: state.balances?.by || "", thresholds: low,
|
|
357
357
|
entries: [...entries, ...subs], lowCount: entries.filter(e => e.low).length, stale: (now() - (state.balances?.ts || 0)) > 6 * 3600e3 });
|
package/lib/providers.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { resolveKeys } from "./provider-keys.mjs";
|
|
|
24
24
|
|
|
25
25
|
export const STATES = ["connected", "not_installed", "not_logged_in", "expired", "over_quota", "unknown"];
|
|
26
26
|
export const ACTIONS = ["login", "paste-key", "recheck", "remove"];
|
|
27
|
+
const DETECTED_BALANCE_PROVIDERS = new Set(["claude", "codex"]);
|
|
27
28
|
|
|
28
29
|
const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
29
30
|
const envKeyName = (p) => `${String(p).toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
@@ -316,7 +317,20 @@ export async function providerStatus(opts = {}) {
|
|
|
316
317
|
now: opts.now || Date.now(),
|
|
317
318
|
probe: opts.probe || balancesProbe,
|
|
318
319
|
};
|
|
319
|
-
|
|
320
|
+
const only = opts.only ? new Set(opts.only.map((name) => String(name).toLowerCase())) : null;
|
|
321
|
+
const providers = only ? PROVIDERS.filter((provider) => only.has(provider.provider)) : PROVIDERS;
|
|
322
|
+
return Promise.all(providers.map((p) => buildRow(p, ctx)));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Claude and Codex are machine logins, not manually-wired API providers. Their balance rows are
|
|
326
|
+
// therefore admitted by the registry's full detection result (binary + auth artifact + live probe),
|
|
327
|
+
// never by the optional quota profile. API-key providers remain profile-scoped in fetchBalances.
|
|
328
|
+
export async function detectedCliBalanceRows(opts = {}) {
|
|
329
|
+
const statuses = await providerStatus({ ...opts, only: [...DETECTED_BALANCE_PROVIDERS] });
|
|
330
|
+
return statuses
|
|
331
|
+
.filter((status) => status.binary.installed && status.auth.present
|
|
332
|
+
&& status.usage && !String(status.usage.error || "").startsWith("not probed"))
|
|
333
|
+
.map((status) => status.usage);
|
|
320
334
|
}
|
|
321
335
|
|
|
322
336
|
// The pre-save seam (#6391's ask): run the registry's OWN probe against a CANDIDATE key and write
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.47",
|
|
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
|
|
14
|
+
"test": "node test/run.mjs"
|
|
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": [
|