trantor 0.18.49 → 0.18.51
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/baton-pane.mjs +62 -9
- package/bin/baton.mjs +8 -2
- package/bin/connect.mjs +44 -4
- package/bin/crew/open.mjs +54 -5
- package/bin/crew-runner.mjs +98 -12
- package/bin/doctor.mjs +61 -1
- package/bin/drill-report.mjs +80 -0
- package/bin/drill-report.test.mjs +146 -0
- package/bin/drill-seams.mjs +157 -0
- package/bin/drill-surface.mjs +157 -54
- package/bin/duty.mjs +68 -1
- package/bin/write-handoff.mjs +7 -1
- package/deploy/setup.sh +38 -0
- package/hooks/lib/handoff.mjs +158 -2
- package/hooks/sessionstart.mjs +10 -0
- package/lib/duty-nudges.mjs +48 -10
- package/lib/state/apply.mjs +170 -0
- package/lib/state/derive.mjs +229 -0
- package/lib/state/gate.mjs +355 -0
- package/lib/state/migrate.mjs +126 -0
- package/lib/state/promote.mjs +122 -0
- package/lib/state/schema.mjs +216 -0
- package/lib/state/store.mjs +442 -0
- package/lib/state/validate.mjs +231 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.51",
|
|
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/baton-pane.mjs
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Env seams (the drill's off switches, same doctrine as TRANTOR_NO_HANDOFF_SPAWN):
|
|
12
12
|
// TRANTOR_BATON_IDLE_DEADLINE_S give up waiting for idle after this many seconds (default 600)
|
|
13
|
+
// TRANTOR_BATON_AGENT_DROP_MS give herdr this long to retire the ended agent (default 10000)
|
|
13
14
|
// TRANTOR_BATON_REOPEN command to reopen the pane, instead of `trantor open`
|
|
14
15
|
// (the drill points this at its own herdr world)
|
|
15
16
|
// Detached means nobody reads stdout: everything lands in <bus>/logs/baton-pane-<project>.log.
|
|
16
17
|
import { readFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
|
|
17
18
|
import { join, basename } from "node:path";
|
|
18
19
|
import { homedir } from "node:os";
|
|
19
|
-
import { execFileSync,
|
|
20
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
20
21
|
import { createConnection } from "node:net";
|
|
21
22
|
|
|
22
23
|
const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : ""; };
|
|
@@ -67,8 +68,50 @@ function socketRequest(req, timeoutMs = 30_000) {
|
|
|
67
68
|
|
|
68
69
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
69
70
|
const agentStatus = (pane) => herdrJson(["agent", "get", pane])?.result?.agent?.agent_status || null;
|
|
71
|
+
const hasAgent = (pane) => Boolean(herdrJson(["agent", "get", pane])?.result?.agent);
|
|
70
72
|
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
71
73
|
|
|
74
|
+
// The shells a pane idles in once its agent is gone. Their pid is never the process to end.
|
|
75
|
+
const SHELL_NAMES = new Set(["zsh", "bash", "sh", "fish", "dash", "ksh", "tcsh", "csh", "nu", "login"]);
|
|
76
|
+
function isShellProcess(p) {
|
|
77
|
+
const raw = String(p?.name || p?.argv0 || "").trim();
|
|
78
|
+
const name = basename(raw.replace(/^-/, "")); // login shells spell themselves "-zsh"
|
|
79
|
+
return SHELL_NAMES.has(name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The pid the graceful end targets, from herdr's `pane process-info` result.process_info —
|
|
83
|
+
* parity with lib.rs foreground_pid_from_process_info. #6668: the 09-07 12:35 chain would have
|
|
84
|
+
* TERMed 80368, the pane's bare zsh, because foreground_process_group_id was taken as-is. The
|
|
85
|
+
* shell (process_info.shell_pid, or any entry named like one) is never a candidate: with only
|
|
86
|
+
* the shell in the foreground there is nothing to end, and the answer is null. */
|
|
87
|
+
export function foregroundPid(info) {
|
|
88
|
+
if (!info) return null;
|
|
89
|
+
const procs = Array.isArray(info.foreground_processes) ? info.foreground_processes : [];
|
|
90
|
+
const shellPid = Number(info.shell_pid) || 0;
|
|
91
|
+
const byPid = (pid) => procs.find(p => Number(p?.pid) === pid);
|
|
92
|
+
const usable = (pid) => pid > 0 && pid !== shellPid && !isShellProcess(byPid(pid));
|
|
93
|
+
const group = Number(info.foreground_process_group_id) || 0;
|
|
94
|
+
if (usable(group)) return group;
|
|
95
|
+
const claude = procs.find(p => /claude/i.test([p?.name, p?.argv0, p?.cmdline].map(s => String(s || "")).join(" ")) && !isShellProcess(p));
|
|
96
|
+
if (claude && Number(claude.pid) > 0 && Number(claude.pid) !== shellPid) return Number(claude.pid);
|
|
97
|
+
for (let i = procs.length - 1; i >= 0; i--) {
|
|
98
|
+
const pid = Number(procs[i]?.pid) || 0;
|
|
99
|
+
if (usable(pid)) return pid;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function agentDropStep(present, elapsedMs, deadlineMs) {
|
|
105
|
+
if (!present) return "dropped";
|
|
106
|
+
return elapsedMs >= deadlineMs ? "deadline" : "wait";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function summary(value, limit = 240) {
|
|
110
|
+
const oneLine = String(value || "").trim().replace(/\s+/g, " ");
|
|
111
|
+
if (!oneLine) return "<empty>";
|
|
112
|
+
return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine;
|
|
113
|
+
}
|
|
114
|
+
|
|
72
115
|
async function main() {
|
|
73
116
|
if (!handoffFile || !existsSync(handoffFile)) { log(`no handoff file (${handoffFile}) — abort`); process.exit(1); }
|
|
74
117
|
const pane = arg("--pane") || orchPane((() => { try { return readFileSync(join(busDir, "crew-windows.txt"), "utf8"); } catch { return ""; } })(), projectName);
|
|
@@ -88,7 +131,7 @@ async function main() {
|
|
|
88
131
|
|
|
89
132
|
// 2. Graceful end, mirroring end_process_gracefully: TERM, short wait, KILL.
|
|
90
133
|
const info = herdrJson(["pane", "process-info", "--pane", pane])?.result?.process_info;
|
|
91
|
-
const pid =
|
|
134
|
+
const pid = foregroundPid(info) || 0;
|
|
92
135
|
if (pid > 0 && alive(pid)) {
|
|
93
136
|
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
94
137
|
const killAt = Date.now() + 10_000;
|
|
@@ -96,18 +139,26 @@ async function main() {
|
|
|
96
139
|
if (alive(pid)) { try { process.kill(pid, "SIGKILL"); } catch {} }
|
|
97
140
|
log(`ended pid ${pid}`);
|
|
98
141
|
} else {
|
|
99
|
-
log("no foreground process to end (already gone)");
|
|
142
|
+
log("no foreground process to end (already gone, or only the pane's shell is in the foreground)");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const dropStarted = Date.now();
|
|
146
|
+
const dropDeadline = Number(process.env.TRANTOR_BATON_AGENT_DROP_MS) || 10_000;
|
|
147
|
+
let dropOutcome;
|
|
148
|
+
while ((dropOutcome = agentDropStep(hasAgent(pane), Date.now() - dropStarted, dropDeadline)) === "wait") {
|
|
149
|
+
await sleep(200);
|
|
100
150
|
}
|
|
151
|
+
log(`agent drop ${dropOutcome} after ${Date.now() - dropStarted}ms`);
|
|
101
152
|
|
|
102
153
|
// 3. Reopen. `trantor open` rebinds orch-sessions.txt and restarts the pane's session — the
|
|
103
154
|
// bookkeeping the classic seam regression comes from skipping. The drill overrides this to
|
|
104
155
|
// stay inside its own herdr world.
|
|
105
156
|
const reopen = process.env.TRANTOR_BATON_REOPEN || "trantor open";
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
log(`reopen FAILED (${
|
|
157
|
+
log(`reopen starting via: ${reopen}`);
|
|
158
|
+
const reopened = spawnSync(reopen, { cwd: projectDir, encoding: "utf8", timeout: 120_000, stdio: "pipe", shell: true });
|
|
159
|
+
log(`reopen result: status=${reopened.status ?? "none"} stdout=${summary(reopened.stdout)} stderr=${summary(reopened.stderr)}`);
|
|
160
|
+
if (reopened.error || reopened.status !== 0) {
|
|
161
|
+
log(`reopen FAILED (${summary(reopened.error?.message || reopened.signal || `exit ${reopened.status}`)}) — handoff waits on disk`);
|
|
111
162
|
process.exit(1);
|
|
112
163
|
}
|
|
113
164
|
|
|
@@ -118,7 +169,9 @@ async function main() {
|
|
|
118
169
|
}
|
|
119
170
|
try {
|
|
120
171
|
const raw = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: { target: pane, text: KICKOFF_PROMPT } });
|
|
121
|
-
|
|
172
|
+
const response = JSON.parse(raw);
|
|
173
|
+
const result = response.result?.type || response.error?.code || "unexpected_response";
|
|
174
|
+
log(`kickoff result: ${result}${response.error?.message ? ` (${summary(response.error.message)})` : ""}`);
|
|
122
175
|
} catch (e) {
|
|
123
176
|
log(`kickoff FAILED (${String(e?.message).slice(0, 120)}) — successor may sit idle until spoken to`);
|
|
124
177
|
}
|
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, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs } from "../hooks/lib/handoff.mjs";
|
|
11
|
+
import { writeHandoff, spawnBaton, resolveHandoffSurface, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs, sessionProcessState } 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
|
|
@@ -82,7 +82,13 @@ function autoBaton() {
|
|
|
82
82
|
// a turn still in flight ARMS instead of writing: no record, no spawn, and the session's own
|
|
83
83
|
// Stop hook fires the baton at the boundary, where the summary describes finished work.
|
|
84
84
|
const force = process.argv.includes("--force");
|
|
85
|
-
|
|
85
|
+
// #6668: a transcript whose session has NO live process is at its boundary, whatever its last
|
|
86
|
+
// row says. The 09-07 12:35 chain armed on a session that had exited at 12:16 (its tail was a
|
|
87
|
+
// tool_result "Connection closed"), then sat in the 17-minute boundary wait for a Stop hook no
|
|
88
|
+
// process would ever run. Write now; the record describes a session that is over.
|
|
89
|
+
const processState = sessionProcessState(sessionId);
|
|
90
|
+
if (processState === "dead") console.log(`session ${sessionId} has no live process — at its boundary, writing now`);
|
|
91
|
+
if (!force && processState !== "dead" && turnInFlight(transcript)) {
|
|
86
92
|
armBaton(sessionId, {
|
|
87
93
|
projectDir: cwd,
|
|
88
94
|
transcript, reason: trigger, windowId: "", tty: controllingTty(),
|
package/bin/connect.mjs
CHANGED
|
@@ -16,6 +16,14 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
const DRY = process.argv.includes("--dry-run");
|
|
17
17
|
const MCP = join(dirname(dirname(fileURLToPath(import.meta.url))), "mcp.mjs");
|
|
18
18
|
const URL_ = process.env.RELAY_URL || "http://127.0.0.1:4477";
|
|
19
|
+
// Graft (github.com/NanoNets/context-graph-engine): a local Tree-sitter dependency graph served
|
|
20
|
+
// over MCP (graft_find_code / _find_all / _trace_calls / _file_api / _repo_map). Wired next to
|
|
21
|
+
// `relay` so a seat can locate code with one call instead of grep+read-many — the graph refreshes
|
|
22
|
+
// itself before each query (no freshness hook) and serves the nearest ancestor with a graft/ index,
|
|
23
|
+
// so it keys off the seat's cwd project. A project with no graft/ index simply returns empty tools,
|
|
24
|
+
// never an error. `graft build` (or `graft init`) seeds a project's index; graft/ is gitignored.
|
|
25
|
+
const GRAFT = (() => { try { return execSync("command -v graft", { encoding: "utf8", shell: "/bin/sh" }).trim(); } catch { return "graft"; } })();
|
|
26
|
+
const HAS_GRAFT = GRAFT !== "graft" || (() => { try { execSync("command -v graft", { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } })();
|
|
19
27
|
const has = (cmd) => { try { execSync(`command -v ${cmd}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
|
|
20
28
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
21
29
|
const backup = (p) => { const b = `${p}.bak-${stamp}`; if (!existsSync(b)) copyFileSync(p, b); return b; };
|
|
@@ -65,6 +73,16 @@ if (has("codex")) {
|
|
|
65
73
|
if (!DRY) { if (existsSync(p)) backup(p); else mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, cur + block); }
|
|
66
74
|
report("codex", cur ? "wired" : "wired (new config)", p);
|
|
67
75
|
}
|
|
76
|
+
// graft alongside relay
|
|
77
|
+
if (HAS_GRAFT) {
|
|
78
|
+
const g = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
79
|
+
if (g.includes("[mcp_servers.graft]")) report("codex", "graft already wired");
|
|
80
|
+
else {
|
|
81
|
+
const gblock = `\n# trantor — Graft code-graph tools (graft_find_code/_find_all/_trace_calls/_file_api/_repo_map)\n[mcp_servers.graft]\ncommand = "${GRAFT}"\nargs = ["mcp"]\n`;
|
|
82
|
+
if (!DRY) { if (existsSync(p)) backup(p); writeFileSync(p, g + gblock); }
|
|
83
|
+
report("codex", "graft wired", p);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
// ---- Gemini CLI ---- (existing relay entries are never overwritten — user customization wins)
|
|
@@ -73,6 +91,7 @@ if (has("gemini")) {
|
|
|
73
91
|
report("gemini", patchJson(p, d => {
|
|
74
92
|
d.mcpServers ||= {};
|
|
75
93
|
d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("gemini") };
|
|
94
|
+
if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
|
|
76
95
|
}), p);
|
|
77
96
|
}
|
|
78
97
|
|
|
@@ -82,6 +101,7 @@ if (has("kimi")) {
|
|
|
82
101
|
report("kimi", patchJson(p, d => {
|
|
83
102
|
d.mcpServers ||= {};
|
|
84
103
|
d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("kimi") };
|
|
104
|
+
if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
|
|
85
105
|
}), p);
|
|
86
106
|
}
|
|
87
107
|
|
|
@@ -92,6 +112,7 @@ if (has("opencode")) {
|
|
|
92
112
|
d.$schema ||= "https://opencode.ai/config.json";
|
|
93
113
|
d.mcp ||= {};
|
|
94
114
|
d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true };
|
|
115
|
+
if (HAS_GRAFT) d.mcp.graft ||= { type: "local", command: [GRAFT, "mcp"], enabled: true };
|
|
95
116
|
d.mcp.relay.environment ||= {};
|
|
96
117
|
// Migrate the old generated pin too: `||=` alone left RELAY_AGENT=opencode in every existing
|
|
97
118
|
// config forever, where OpenCode overlaid it on the qwen/glm/deepseek runner environment.
|
|
@@ -135,7 +156,7 @@ if (has("dsh")) {
|
|
|
135
156
|
},
|
|
136
157
|
dsh: { profile: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless"] } },
|
|
137
158
|
};
|
|
138
|
-
const patch = `# trantor — generated by \`trantor connect\` (
|
|
159
|
+
const patch = `# trantor — generated by \`trantor connect\` (connect rewrites this file only when it is missing a row; delete it to force a full regen)
|
|
139
160
|
- insert:
|
|
140
161
|
- id: trantor-cc-hooks
|
|
141
162
|
name: '@deepseek-ai/dsh-hooks-claude-code'
|
|
@@ -154,10 +175,29 @@ if (has("dsh")) {
|
|
|
154
175
|
RELAY_AGENT: !!js process.env.RELAY_AGENT ?? 'dsh'
|
|
155
176
|
RELAY_PROJECT: !!js process.env.RELAY_PROJECT ?? ''
|
|
156
177
|
RELAY_SESSION: !!js process.env.RELAY_SESSION ?? ''
|
|
157
|
-
|
|
178
|
+
${HAS_GRAFT ? ` - id: trantor-graft
|
|
179
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
180
|
+
config:
|
|
181
|
+
serverName: graft
|
|
182
|
+
transport: stdio
|
|
183
|
+
command: ${GRAFT}
|
|
184
|
+
args: ['mcp']
|
|
185
|
+
` : ""}`;
|
|
186
|
+
// "a profile exists" is not "a profile is current": connect grows rows over time (relay, then
|
|
187
|
+
// graft, then whatever comes next), and an existence check short-circuits on a profile written
|
|
188
|
+
// by an older connect forever — the seat silently never gets the new row. So the gate is
|
|
189
|
+
// CONTENT-based: every row id this connect would write must already be in the patch; a missing
|
|
190
|
+
// one regenerates the patch (backed up). Presence, not diff — user edits to rows that ARE there
|
|
191
|
+
// still win, the same rule as the gemini/kimi/opencode `||=` patches above.
|
|
192
|
+
const expectedIds = [...patch.matchAll(/- id: (\S+)/g)].map(m => m[1]);
|
|
193
|
+
const cur = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "";
|
|
194
|
+
const missing = expectedIds.filter(id => !cur.split("\n").some(l => l.trim() === `- id: ${id}`));
|
|
158
195
|
const fresh = !existsSync(patchPath);
|
|
159
|
-
if (!fresh) report("dsh", "already wired", prof);
|
|
160
|
-
else {
|
|
196
|
+
if (!fresh && !missing.length) report("dsh", "already wired", prof);
|
|
197
|
+
else if (!fresh) {
|
|
198
|
+
if (!DRY) { backup(patchPath); writeFileSync(patchPath, patch); }
|
|
199
|
+
report("dsh", `regenerated — was missing: ${missing.join(", ")}`, prof);
|
|
200
|
+
} else {
|
|
161
201
|
if (!DRY) {
|
|
162
202
|
mkdirSync(prof, { recursive: true });
|
|
163
203
|
// The seat runs the plugin's hooks MINUS SessionStart: the crew runner already owns
|
package/bin/crew/open.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { existsSync, openSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { spawn, execSync } from "node:child_process";
|
|
6
|
+
import { call, parseJsonOutput } from "./core.mjs";
|
|
7
|
+
import { createWorkspace, herdrCall, reportAgent, splitPane, workspaceList, workspacePane } from "./herdr.mjs";
|
|
6
8
|
import { dropState, readRows, recordState } from "./state.mjs";
|
|
7
9
|
import { resolveOrchestratorDir } from "./worktrees.mjs";
|
|
8
10
|
|
|
@@ -72,6 +74,19 @@ function orchestratorCommand(ctx, id) {
|
|
|
72
74
|
return `${env} claude${harnessFlag(ctx)} ${action}`;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
export function paneHasAgent(ctx, pane) {
|
|
78
|
+
const result = herdrCall(ctx, ["pane", "process-info", "--pane", pane]);
|
|
79
|
+
const info = parseJsonOutput(result.stdout)?.result?.process_info;
|
|
80
|
+
if (!result.ok || !info) return false;
|
|
81
|
+
const processes = Array.isArray(info.foreground_processes) ? info.foreground_processes : [];
|
|
82
|
+
return processes.some(process => {
|
|
83
|
+
const command = [process.name, process.argv0, process.cmdline, ...(Array.isArray(process.argv) ? process.argv : [])]
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.join(" ");
|
|
86
|
+
return /(^|[/\s])claude(?:\.exe)?(?:$|\s)/i.test(command);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
function tracked(ctx, live) {
|
|
76
91
|
let workspace = "";
|
|
77
92
|
let pane = "";
|
|
@@ -91,7 +106,8 @@ function reattach(ctx, workspace, pane, id) {
|
|
|
91
106
|
const renamed = herdrCall(ctx, ["pane", "rename", pane, `orchestrator · ${ctx.project}`]);
|
|
92
107
|
if (!renamed.ok) { dropState(ctx, ctx.project, "orch"); return false; }
|
|
93
108
|
if (!paneHasAgent(ctx, pane)) {
|
|
94
|
-
herdrCall(ctx, ["pane", "run", pane, orchestratorCommand(ctx, id)]);
|
|
109
|
+
const started = herdrCall(ctx, ["pane", "run", pane, orchestratorCommand(ctx, id)]);
|
|
110
|
+
if (!started.ok) throw new Error(`trantor open: could not start the orchestrator in pane ${pane}`);
|
|
95
111
|
reportAgent(ctx, pane, "claude");
|
|
96
112
|
console.error(`— orchestrator pane was empty: resumed session ${id} in herdr:${workspace || "?"}/${pane} —`);
|
|
97
113
|
} else console.error(`— orchestrator already hosted: reattached to herdr:${workspace || "?"}/${pane} —`);
|
|
@@ -136,6 +152,38 @@ function hostPane(ctx, chosen, id) {
|
|
|
136
152
|
return pane;
|
|
137
153
|
}
|
|
138
154
|
|
|
155
|
+
// #6888: index the project once so its seats' Graft tools (graft_find_code, _trace_calls, …) have a
|
|
156
|
+
// graph to serve. Non-blocking — the pane opens now, the ~7s build lands in the background; a no-op
|
|
157
|
+
// if graft isn't installed or the index already exists (the graph self-refreshes per query after the
|
|
158
|
+
// first build). Keeps the 20MB index out of the project's git.
|
|
159
|
+
export function maybeBuildGraft(dir) {
|
|
160
|
+
if (!dir) return;
|
|
161
|
+
try { execSync("command -v graft", { stdio: "ignore", shell: "/bin/sh" }); } catch { return; }
|
|
162
|
+
try {
|
|
163
|
+
if (existsSync(join(dir, "graft", ".graph"))) return;
|
|
164
|
+
const gi = join(dir, ".gitignore");
|
|
165
|
+
try {
|
|
166
|
+
const cur = existsSync(gi) ? readFileSync(gi, "utf8") : "";
|
|
167
|
+
if (!/^graft\/?$/m.test(cur)) writeFileSync(gi, cur + (cur && !cur.endsWith("\n") ? "\n" : "") + "graft/\n");
|
|
168
|
+
} catch {}
|
|
169
|
+
// The build is backgrounded, so we cannot gate the pane on its exit code — but a failure has to
|
|
170
|
+
// land SOMEWHERE. It used to go to stdio:"ignore", which meant a graft whose native parsers were
|
|
171
|
+
// never built (npm v12 skips node-gyp by default; see deploy/setup.sh) failed in total silence
|
|
172
|
+
// while the line below still claimed an index was being built.
|
|
173
|
+
//
|
|
174
|
+
// The log goes to the OS temp dir rather than the project: nothing to gitignore, nothing to clean
|
|
175
|
+
// up, and no exit handler — after unref() the parent usually dies first, so an on-exit cleanup
|
|
176
|
+
// would be a promise we cannot keep. It is simply overwritten by the next open of this project.
|
|
177
|
+
const log = join(tmpdir(), `trantor-graft-build-${basename(dir)}.log`);
|
|
178
|
+
let fd = "ignore";
|
|
179
|
+
try { fd = openSync(log, "w"); } catch {}
|
|
180
|
+
const child = spawn("graft", ["build", dir], { cwd: dir, stdio: ["ignore", fd, fd], detached: true });
|
|
181
|
+
child.on("error", () => {}); // ENOENT/EACCES: the log and the absent index are the evidence
|
|
182
|
+
child.unref();
|
|
183
|
+
console.error(`— indexing this project for the seats' Graft tools in the background (failures land in ${log}) —`);
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
|
|
139
187
|
export function openOrchestrator(ctx, args) {
|
|
140
188
|
let parsed;
|
|
141
189
|
try { parsed = parseArgs(args); }
|
|
@@ -143,6 +191,7 @@ export function openOrchestrator(ctx, args) {
|
|
|
143
191
|
if (parsed.help) { usage(); return 0; }
|
|
144
192
|
try {
|
|
145
193
|
Object.assign(ctx, resolveOrchestratorDir(ctx, parsed.project));
|
|
194
|
+
if (!ctx.dry) maybeBuildGraft(ctx.dir);
|
|
146
195
|
if (!ctx.have.herdr) throw new Error("trantor open needs herdr (the pane host) — install: curl -fsSL https://herdr.dev/install.sh | sh");
|
|
147
196
|
let id = sessionId(ctx);
|
|
148
197
|
if (hasPendingHandoff(ctx)) {
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
senderProjectOf, isLinkedProject,
|
|
29
29
|
} from "../lib/turn-policy.mjs";
|
|
30
30
|
import {
|
|
31
|
-
auditDutyNudges,
|
|
31
|
+
auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
|
|
32
|
+
observedDutyNudgeIds,
|
|
32
33
|
} from "../lib/duty-nudges.mjs";
|
|
33
34
|
|
|
34
35
|
const AGENT = process.argv[2];
|
|
@@ -468,10 +469,40 @@ async function parkSeat(reason, undelivered, resetHint = 0) {
|
|
|
468
469
|
if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
|
|
469
470
|
}
|
|
470
471
|
log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
|
|
472
|
+
// The two /send calls above are the whole escalation, and on 2026-09-09 that was not enough:
|
|
473
|
+
// the DUTY seat parked on a quota read, held 48 messages for 21.9 hours, and announced it over
|
|
474
|
+
// the very bus that had stopped moving, to an orchestrator that was idle and therefore could not
|
|
475
|
+
// receive it. The alarm for "the bus is stuck" cannot itself be a bus message. So park also
|
|
476
|
+
// rings a bell the operator can actually hear, out of band, once per park.
|
|
477
|
+
notifyOperator(`Trantor: ${SESSION} PARKED (${reason})`,
|
|
478
|
+
`${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
|
|
471
479
|
// No reset time means no timer can clear it: hold until the operator restarts the seat.
|
|
472
480
|
return resetAt || Number.MAX_SAFE_INTEGER;
|
|
473
481
|
}
|
|
474
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Reach the operator on a channel that does not depend on the bus, the hub, or a live session.
|
|
485
|
+
* Best-effort and strictly non-fatal: a seat must never die because a notifier is missing.
|
|
486
|
+
* Silence-able with TRANTOR_NO_DESKTOP_NOTIFY=1 for headless boxes and test runs.
|
|
487
|
+
*/
|
|
488
|
+
function notifyOperator(title, body) {
|
|
489
|
+
if (process.env.TRANTOR_NO_DESKTOP_NOTIFY === "1") return;
|
|
490
|
+
try {
|
|
491
|
+
// Always leave a durable trace first: a notification can be missed or suppressed, a file cannot.
|
|
492
|
+
// This is what `trantor doctor` reads, so the escalation survives a machine nobody was sitting at.
|
|
493
|
+
const alertsPath = join(homedir(), ".agent-bus", "alerts.jsonl");
|
|
494
|
+
appendFileSync(alertsPath, `${JSON.stringify({ ts: Date.now(), session: SESSION, title, body })}\n`);
|
|
495
|
+
} catch {}
|
|
496
|
+
try {
|
|
497
|
+
if (process.platform === "darwin") {
|
|
498
|
+
// osascript is present on every mac; no dependency to install and nothing to keep running.
|
|
499
|
+
const esc = (s) => String(s).replace(/["\\]/g, "\\$&");
|
|
500
|
+
spawnSync("osascript", ["-e", `display notification "${esc(body)}" with title "${esc(title)}"`],
|
|
501
|
+
{ timeout: 5000, stdio: "ignore" });
|
|
502
|
+
}
|
|
503
|
+
} catch {}
|
|
504
|
+
}
|
|
505
|
+
|
|
475
506
|
// The seat's own balance rows, for the #6131 read: a stalled turn that printed nothing on a seat
|
|
476
507
|
// whose plan is spent is exhaustion, not a crash. Bounded and best-effort — a slow provider API
|
|
477
508
|
// must never hold up the failure path, and an unreachable one just leaves the reason as it was.
|
|
@@ -886,7 +917,24 @@ function isRunnerSession(session) {
|
|
|
886
917
|
return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
|
|
887
918
|
}
|
|
888
919
|
|
|
920
|
+
// A hub staleness alert describes a condition that was true for a moment: "#16909 has been
|
|
921
|
+
// UNDELIVERED for 2m — go nudge someone". Acting on it 22 hours later is meaningless, and the queue
|
|
922
|
+
// had no expiry, so on 2026-09-09 the duty seat's backlog became SELF-POISONING: the hub kept
|
|
923
|
+
// noticing undelivered mail and sending more alerts, duty could not work them off, and a restart
|
|
924
|
+
// faithfully redelivered 49 dead nudges and re-wedged the seat. 46 of those 49 were hub alerts, the
|
|
925
|
+
// oldest 22.1 hours old, every one describing a two-minute condition.
|
|
926
|
+
//
|
|
927
|
+
// So these EXPIRE. Deliberately narrow: only messages the HUB generated about staleness, never a
|
|
928
|
+
// message from a peer. A real contract is never dropped for being old — a seat that misses a
|
|
929
|
+
// teammate's request is the failure this bus exists to prevent, and no backlog is worth causing it.
|
|
930
|
+
const HUB_ALERT_TTL_MS = Number(process.env.TRANTOR_HUB_ALERT_TTL_MS || 30 * 60_000);
|
|
931
|
+
const isExpiredHubAlert = (m) =>
|
|
932
|
+
m?.from === "hub:duty" &&
|
|
933
|
+
Number.isFinite(m?.ts) &&
|
|
934
|
+
Date.now() - m.ts > HUB_ALERT_TTL_MS;
|
|
935
|
+
|
|
889
936
|
function shouldWake(message) {
|
|
937
|
+
if (isExpiredHubAlert(message)) return false;
|
|
890
938
|
if (isReceipt(message) || isStatusBroadcast(message)) return false;
|
|
891
939
|
// #6134: the SENDER decides. `wake:false` says "this is context, not a contract" — it batches
|
|
892
940
|
// into the next turn's prompt like a broadcast and never buys a CLI session of its own.
|
|
@@ -956,8 +1004,14 @@ function askedExcerpt(message) {
|
|
|
956
1004
|
// broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
|
|
957
1005
|
// (or a machine that rebooted) still owes those messages, and the hub will never send them again.
|
|
958
1006
|
const restored = loadPending();
|
|
1007
|
+
// Say what the restore SHED, not just what it kept. A queue that quietly halves itself on restart
|
|
1008
|
+
// is indistinguishable from one that lost real work, and this is the moment the expiry above
|
|
1009
|
+
// actually bites — a wedged seat comes back carrying only what still means something.
|
|
1010
|
+
const shed = restored.wake.filter(isExpiredHubAlert).length +
|
|
1011
|
+
restored.bcast.filter(isExpiredHubAlert).length;
|
|
959
1012
|
let pendingWake = restored.wake.filter(shouldWake);
|
|
960
|
-
let pendingBcast = restored.bcast.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
|
|
1013
|
+
let pendingBcast = restored.bcast.filter(m => !isExpiredHubAlert(m) && !isReceipt(m) && !isStatusBroadcast(m));
|
|
1014
|
+
if (shed) log(`\x1b[33mdropped ${shed} expired hub staleness alert(s) older than ${Math.round(HUB_ALERT_TTL_MS / 60000)}m — they describe conditions that have long since changed\x1b[0m`);
|
|
961
1015
|
let retryAt = 0; // 0 = deliver at the next opportunity
|
|
962
1016
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
963
1017
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
@@ -1067,10 +1121,30 @@ function askedExcerpt(message) {
|
|
|
1067
1121
|
// queued, on disk, with a backoff — which is the whole point of the change.
|
|
1068
1122
|
async function deliverWake() {
|
|
1069
1123
|
const wake = pendingWake;
|
|
1070
|
-
const
|
|
1124
|
+
const dutyPlan = DUTY_NUDGES
|
|
1125
|
+
? await claimDutyNudges({
|
|
1126
|
+
messages: wake,
|
|
1127
|
+
statePath: DUTY_NUDGE_STATE,
|
|
1128
|
+
owner: `${RUNNER_ID}:${TURN + 1}`,
|
|
1129
|
+
})
|
|
1130
|
+
: { items: [], targets: [], owner: "" };
|
|
1131
|
+
const claimedIds = new Set(dutyPlan.items.map(item => item.id));
|
|
1132
|
+
const wakeForTurn = DUTY_NUDGES
|
|
1133
|
+
? wake.filter(message => {
|
|
1134
|
+
const escalation = dutyEscalations([message])[0];
|
|
1135
|
+
return !escalation || claimedIds.has(escalation.id);
|
|
1136
|
+
})
|
|
1137
|
+
: wake;
|
|
1138
|
+
if (!wakeForTurn.length) {
|
|
1139
|
+
pendingWake = [];
|
|
1140
|
+
savePending([], pendingBcast);
|
|
1141
|
+
log("duty escalation already nudged or reserved by another turn — consumed without a duplicate model wake");
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const wakeCapped = capWake(wakeForTurn);
|
|
1071
1145
|
const bcastCapped = capBcast(pendingBcast);
|
|
1072
1146
|
const wakeText = wakeCapped.text
|
|
1073
|
-
? `NEW BUS MESSAGE${
|
|
1147
|
+
? `NEW BUS MESSAGE${wakeForTurn.length > 1 ? "S" : ""} for you:\n${wakeCapped.text}\n`
|
|
1074
1148
|
: "";
|
|
1075
1149
|
const ctxText = bcastCapped.text
|
|
1076
1150
|
? `\nFYI broadcasts since your last turn (context only):\n${bcastCapped.text}\n`
|
|
@@ -1078,29 +1152,26 @@ function askedExcerpt(message) {
|
|
|
1078
1152
|
// Say plainly that this is a second look. Without it the model re-reads an old escalation as
|
|
1079
1153
|
// brand new and can redo work it already half-did before the turn died.
|
|
1080
1154
|
const againText = deliveryFails
|
|
1081
|
-
? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${
|
|
1155
|
+
? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${wakeForTurn.length > 1 ? "these" : "this"}. Check what you already did before repeating it.)\n`
|
|
1082
1156
|
: "";
|
|
1083
1157
|
await loadLessons();
|
|
1084
1158
|
const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
|
|
1085
|
-
const trigger =
|
|
1159
|
+
const trigger = wakeForTurn.some(m => m.to === SESSION) ? "direct message" : "@mention";
|
|
1086
1160
|
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
|
|
1087
1161
|
const assigners = [];
|
|
1088
|
-
for (const m of
|
|
1089
|
-
const asked = askedExcerpt(
|
|
1162
|
+
for (const m of wakeForTurn) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
|
|
1163
|
+
const asked = askedExcerpt(wakeForTurn[0]);
|
|
1090
1164
|
const tStart = Date.now();
|
|
1091
1165
|
// #6134: ONE SESSION PER CARD. A seat that resumes forever carries every card it ever worked
|
|
1092
1166
|
// into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
|
|
1093
1167
|
// card that moved this wake decides: a different one starts a fresh CLI session, and the seat
|
|
1094
1168
|
// is told so, because a fresh session remembers nothing and must be sent to its card.
|
|
1095
|
-
const card =
|
|
1169
|
+
const card = wakeForTurn.map(m => cardRef(m.text)).find(Boolean) || 0;
|
|
1096
1170
|
const fresh = card > 0 && card !== sessionCard;
|
|
1097
1171
|
if (card) sessionCard = card;
|
|
1098
1172
|
const freshText = fresh
|
|
1099
1173
|
? `\n(FRESH SESSION for card #${card} — you are not the session that worked earlier cards and you remember none of them. Read your card first: relay_board with card:${card}.)\n`
|
|
1100
1174
|
: "";
|
|
1101
|
-
const dutyPlan = DUTY_NUDGES
|
|
1102
|
-
? planDutyNudges(wake, DUTY_NUDGE_STATE)
|
|
1103
|
-
: { items: [], targets: [] };
|
|
1104
1175
|
const prompt = composedTurn({
|
|
1105
1176
|
wakeText, ctxText, againText: againText + freshText + dutyNudgeDirective(dutyPlan),
|
|
1106
1177
|
tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
|
|
@@ -1165,6 +1236,21 @@ function askedExcerpt(message) {
|
|
|
1165
1236
|
const parkReason = PARKING_REASONS.has(reason) ? reason : (lastTurnCut ? "time-box" : "api-error");
|
|
1166
1237
|
if (PARKING_REASONS.has(reason) || deliveryFails >= 2) {
|
|
1167
1238
|
retryAt = await parkSeat(parkReason, pendingWake.length, quotaReset);
|
|
1239
|
+
// A supervised seat does not have to sit parked until someone notices. RUNNER_PARK_MAX_MS
|
|
1240
|
+
// is set only by `trantor duty up`, which runs the seat under a launchd keepalive: past the
|
|
1241
|
+
// ceiling, exit and let the supervisor restart it clean — a fresh process re-reads auth and
|
|
1242
|
+
// redelivers the queue from disk, which is exactly what un-wedged the 2026-09-09 incident
|
|
1243
|
+
// when the operator finally ran `trantor duty up` by hand 21.9 hours late.
|
|
1244
|
+
// Unsupervised seats keep the old behaviour: exiting would just kill them for good.
|
|
1245
|
+
const parkMax = Number(process.env.RUNNER_PARK_MAX_MS || 0);
|
|
1246
|
+
if (parkMax > 0) {
|
|
1247
|
+
const wakeIn = Math.max(0, Math.min(retryAt - Date.now(), parkMax));
|
|
1248
|
+
log(`\x1b[33msupervised seat: exiting in ${Math.round(wakeIn / 1000)}s so the keepalive restarts it clean\x1b[0m`);
|
|
1249
|
+
setTimeout(() => {
|
|
1250
|
+
log("parked past the ceiling — exiting for the keepalive to relaunch");
|
|
1251
|
+
process.exit(0); // 0, not 1: this is a deliberate hand-off, not a crash
|
|
1252
|
+
}, wakeIn).unref?.();
|
|
1253
|
+
}
|
|
1168
1254
|
await notifyAssigners(assigners,
|
|
1169
1255
|
`⛔ your contract is PARKED on ${SESSION} (${parkReason}) — not retrying · asked: "${asked}"`);
|
|
1170
1256
|
lastTurnAt = Date.now();
|
package/bin/doctor.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Checks: runtime, hub, plugin, each CLI (installed? wired? AUTHENTICATED?), API keys,
|
|
4
4
|
// quota profile, optional Scrooge brain. Prints a checklist with copy-paste fixes.
|
|
5
5
|
// node bin/doctor.mjs
|
|
6
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
@@ -304,6 +304,66 @@ prof?.providers && Object.keys(prof.providers).length
|
|
|
304
304
|
? ok(`quota profile set (${Object.entries(prof.providers).map(([k, v]) => `${k}=${v.plan}`).join(", ")})`)
|
|
305
305
|
: warn("quota profile not set — the Advisor will assume API billing everywhere", `node ${join(ROOT, "bin", "profile.mjs")} set claude=max codex=plus deepseek=api … (use YOUR real plans)`);
|
|
306
306
|
|
|
307
|
+
// fleet — is the crew actually OPERATING, not just installed?
|
|
308
|
+
//
|
|
309
|
+
// Added 2026-09-09, because on that morning this command reported nine issues, every one about
|
|
310
|
+
// provider keys and billing attribution, while the duty seat had been holding 48 undelivered
|
|
311
|
+
// messages for 21.9 hours and the orchestrator had slept through a night of finished crew work.
|
|
312
|
+
// Doctor checked whether credentials EXIST. Nothing checked whether the fleet was MOVING. These
|
|
313
|
+
// two signals are both already on disk, written by the runner itself — nobody was reading them.
|
|
314
|
+
section("the fleet (is it actually running?)");
|
|
315
|
+
{
|
|
316
|
+
const busDir = join(H, ".agent-bus");
|
|
317
|
+
// 1. Undelivered queues. crew-runner persists pending-<agent>-<project>.json on every failed
|
|
318
|
+
// delivery and unlinks it when the queue drains, so a file with an old head means mail is
|
|
319
|
+
// stuck for that seat — whatever its process table says.
|
|
320
|
+
let stuck = 0;
|
|
321
|
+
const abandonedSeats = [];
|
|
322
|
+
try {
|
|
323
|
+
for (const f of readdirSync(busDir).filter(n => n.startsWith("pending-") && n.endsWith(".json"))) {
|
|
324
|
+
const j = read(join(busDir, f));
|
|
325
|
+
const held = [...(j?.wake || []), ...(j?.bcast || [])];
|
|
326
|
+
if (!held.length) continue;
|
|
327
|
+
const stamps = held.map(m => m?.ts).filter(Number.isFinite);
|
|
328
|
+
const oldest = stamps.length ? Math.min(...stamps) : j?.ts;
|
|
329
|
+
const hours = (Date.now() - oldest) / 3.6e6;
|
|
330
|
+
const seat = f.replace(/^pending-|\.json$/g, "");
|
|
331
|
+
// Three bands, because one flat warning per stuck queue is its own failure: this machine has
|
|
332
|
+
// leftovers from projects that ended weeks ago, and a doctor that cries about ten of them
|
|
333
|
+
// every run teaches you to skim past the one that matters. Under an hour is the retry ladder
|
|
334
|
+
// doing its job. Over a week is an abandoned seat, worth tidying, not worth alarming about.
|
|
335
|
+
// The band between is the live stall — the shape of the 2026-09-09 incident.
|
|
336
|
+
const abandoned = hours >= 24 * 7;
|
|
337
|
+
if (hours >= 1 && !abandoned) {
|
|
338
|
+
stuck++;
|
|
339
|
+
warn(`${seat}: ${held.length} message(s) undelivered, oldest ${hours.toFixed(1)}h old — mail is not moving`,
|
|
340
|
+
`the runner parks on quota/api failure and only a restart un-parks it: trantor up ${seat.split("-")[0]} (duty: trantor duty up)`);
|
|
341
|
+
} else if (abandoned) {
|
|
342
|
+
abandonedSeats.push(`${seat} (${(hours / 24).toFixed(0)}d)`);
|
|
343
|
+
} else {
|
|
344
|
+
note(`${seat}: ${held.length} queued, oldest ${hours.toFixed(1)}h — within the retry ladder`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (abandonedSeats.length) {
|
|
348
|
+
note(`${abandonedSeats.length} abandoned queue(s) older than a week: ${abandonedSeats.join(", ")} — leftovers from finished work, safe to delete`);
|
|
349
|
+
}
|
|
350
|
+
if (!stuck) ok("no live seat is sitting on undelivered mail");
|
|
351
|
+
} catch { note(`no bus directory at ${busDir} yet — nothing has run`); }
|
|
352
|
+
|
|
353
|
+
// 2. Park alerts. notifyOperator appends one line per park, so a park that happened while nobody
|
|
354
|
+
// was at the machine is still visible here afterwards — the point of writing it to disk.
|
|
355
|
+
try {
|
|
356
|
+
const alerts = readFileSync(join(busDir, "alerts.jsonl"), "utf8").trim().split("\n").filter(Boolean);
|
|
357
|
+
const recent = alerts.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
358
|
+
.filter(a => a && Date.now() - a.ts < 24 * 3.6e6);
|
|
359
|
+
if (recent.length) {
|
|
360
|
+
const last = recent[recent.length - 1];
|
|
361
|
+
warn(`${recent.length} seat park alert(s) in the last 24h — most recent: ${last.title}`,
|
|
362
|
+
`read them: tail ~/.agent-bus/alerts.jsonl — then restart the seat named above`);
|
|
363
|
+
} else ok("no seat has parked in the last 24h");
|
|
364
|
+
} catch { ok("no seat has parked in the last 24h"); }
|
|
365
|
+
}
|
|
366
|
+
|
|
307
367
|
say(issues ? `\n${issues} issue(s) — fix the → lines above, then re-run the doctor.` : "\nAll clear — open a claude session in any project and say: \"fire up the crew\".");
|
|
308
368
|
// Must come BEFORE the exit — process.exit() here truncated the report entirely.
|
|
309
369
|
if (JSON_MODE) console.log(JSON.stringify({ ...REPORT, issueCount: issues }));
|