agent-coord-mcp 0.26.16 → 0.26.18
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/hooks/replay.mjs +107 -0
- package/hooks/tier.mjs +11 -0
- package/hooks/tmux-pusher.mjs +27 -0
- package/package.json +1 -1
- package/scripts/coord-pusher.mjs +16 -32
- package/src/server.ts +40 -20
- package/src/tools/admin.ts +8 -1
- package/src/tools/away.ts +48 -4
- package/src/tools/board-ref.ts +47 -2
- package/src/tools/messaging.ts +43 -2
- package/src/tools/records.ts +381 -25
- package/src/tools/registry.ts +5 -2
- package/src/tools/rooms.ts +2 -1
- package/src/tools/shared.ts +28 -0
- package/src/tools/stall.ts +29 -1
- package/src/tools/transport.ts +79 -10
- package/src/tools/work.ts +70 -4
- package/src/tools/worktrees.ts +64 -2
- package/src/work.ts +4 -0
- package/dist/build.js +0 -113
- package/dist/build.js.map +0 -1
- package/dist/capabilities.js +0 -158
- package/dist/capabilities.js.map +0 -1
- package/dist/prefix.js +0 -64
- package/dist/prefix.js.map +0 -1
- package/dist/roles.js +0 -132
- package/dist/roles.js.map +0 -1
- package/dist/server-identity.js +0 -82
- package/dist/server-identity.js.map +0 -1
- package/dist/server.js +0 -625
- package/dist/server.js.map +0 -1
- package/dist/store.js +0 -553
- package/dist/store.js.map +0 -1
- package/dist/tools/admin.js +0 -317
- package/dist/tools/admin.js.map +0 -1
- package/dist/tools/attention.js +0 -73
- package/dist/tools/attention.js.map +0 -1
- package/dist/tools/away.js +0 -243
- package/dist/tools/away.js.map +0 -1
- package/dist/tools/board-ref.js +0 -164
- package/dist/tools/board-ref.js.map +0 -1
- package/dist/tools/event-kinds.js +0 -39
- package/dist/tools/event-kinds.js.map +0 -1
- package/dist/tools/events.js +0 -234
- package/dist/tools/events.js.map +0 -1
- package/dist/tools/index.js +0 -14
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/logwatch.js +0 -85
- package/dist/tools/logwatch.js.map +0 -1
- package/dist/tools/messaging.js +0 -667
- package/dist/tools/messaging.js.map +0 -1
- package/dist/tools/record-events.js +0 -380
- package/dist/tools/record-events.js.map +0 -1
- package/dist/tools/records.js +0 -686
- package/dist/tools/records.js.map +0 -1
- package/dist/tools/registry.js +0 -497
- package/dist/tools/registry.js.map +0 -1
- package/dist/tools/render.js +0 -2
- package/dist/tools/render.js.map +0 -1
- package/dist/tools/rooms.js +0 -210
- package/dist/tools/rooms.js.map +0 -1
- package/dist/tools/rotate.js +0 -143
- package/dist/tools/rotate.js.map +0 -1
- package/dist/tools/scopes.js +0 -126
- package/dist/tools/scopes.js.map +0 -1
- package/dist/tools/shared.js +0 -86
- package/dist/tools/shared.js.map +0 -1
- package/dist/tools/stall.js +0 -387
- package/dist/tools/stall.js.map +0 -1
- package/dist/tools/transport.js +0 -1810
- package/dist/tools/transport.js.map +0 -1
- package/dist/tools/work.js +0 -319
- package/dist/tools/work.js.map +0 -1
- package/dist/tools/worktrees.js +0 -339
- package/dist/tools/worktrees.js.map +0 -1
- package/dist/typed-records.js +0 -174
- package/dist/typed-records.js.map +0 -1
- package/dist/work.js +0 -2
- package/dist/work.js.map +0 -1
package/dist/tools/transport.js
DELETED
|
@@ -1,1810 +0,0 @@
|
|
|
1
|
-
import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
|
|
2
|
-
import { newestMtimeUnder, onDiskBuildMtime, onDiskSourceMtime, SERVER_BUILD_MTIME, SERVER_BUILD_SHA, BUILD_DIR } from "../build.js";
|
|
3
|
-
import { prefixOf, prefixVerdict } from "../prefix.js";
|
|
4
|
-
import { execFileSync } from "node:child_process";
|
|
5
|
-
import { registerTool } from "./registry.js";
|
|
6
|
-
import { roleInputSchema } from "../roles.js";
|
|
7
|
-
import { attributeWriter, isGitRepo, lastWriterOf, loadScopes, ownsDocument } from "./scopes.js";
|
|
8
|
-
// Moved to a LEAF so `status` can report capabilities without a cycle — see
|
|
9
|
-
// src/server-identity.ts. Re-exported so every existing importer is untouched.
|
|
10
|
-
export { resolveServerIdentity } from "../server-identity.js";
|
|
11
|
-
import { resolveServerIdentity } from "../server-identity.js";
|
|
12
|
-
// ONE-WAY EDGE, and the direction is the point: `status` CALLS the capabilities
|
|
13
|
-
// verb rather than re-deriving its probes. Two probes of the same thing is the
|
|
14
|
-
// four-matchers defect committed deliberately, which is why 17.4 was deferred
|
|
15
|
-
// behind 13.7 instead of built beside it.
|
|
16
|
-
import { capabilitiesTool } from "../capabilities.js";
|
|
17
|
-
import { sendMessageTool, readMessagesTool } from "./messaging.js";
|
|
18
|
-
import { randomUUID } from "node:crypto";
|
|
19
|
-
import { existsSync, openSync } from "node:fs";
|
|
20
|
-
import { promises as fsp } from "node:fs";
|
|
21
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
22
|
-
import { fileURLToPath } from "node:url";
|
|
23
|
-
import { z } from "zod";
|
|
24
|
-
import path from "node:path";
|
|
25
|
-
import { AGENTS_FILE, CURSOR_DIR, DEFAULT_ROOM, INBOX_DIR, ROOT, ROOM_FILE, ROOMS_DIR, ROOMS_FILE, STATUS_FILE, appendJsonl, cursorFile, deleteFile, ensureRoom, fileSize, getRooms, inboxFile, listCursorFiles, listInboxFiles, listSessionFiles, listTransportFiles, logFile, normalizeRoom, pidFile, readJson, readJsonl, receiptFile, rewriteJsonl, roomFile, transportFile, TRANSPORT_DIR, updateJson, } from "../store.js";
|
|
26
|
-
import { STALE_MS, EVICT_MS, } from "./shared.js";
|
|
27
|
-
// ---------- ping ----------
|
|
28
|
-
export const pingSchema = {
|
|
29
|
-
from: z.string().min(1),
|
|
30
|
-
to: z.string().min(1),
|
|
31
|
-
echo: z.boolean().optional(),
|
|
32
|
-
};
|
|
33
|
-
// Liveness probe answered entirely from server-side state — registry entry,
|
|
34
|
-
// transport marker, pusher pid, tmux pane. It never touches the target's
|
|
35
|
-
// session, so a fleet-wide sweep costs zero model tokens on the targets.
|
|
36
|
-
// Distinct from `heartbeat` (the target refreshing its own activity
|
|
37
|
-
// timestamp): ping is a third party asking "would a DM land right now?".
|
|
38
|
-
// echo=true is the one exception — it drops a PING DM into the target's inbox
|
|
39
|
-
// (normal delivery, so the target's model DOES wake); opt-in, default off.
|
|
40
|
-
export async function pingTool(args) {
|
|
41
|
-
const t0 = process.hrtime.bigint();
|
|
42
|
-
const now = Date.now();
|
|
43
|
-
const latencyMs = () => Math.round(Number(process.hrtime.bigint() - t0) / 1e3) / 1e3;
|
|
44
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
45
|
-
const entry = reg[args.to];
|
|
46
|
-
if (!entry) {
|
|
47
|
-
return { ok: true, to: args.to, alive: false, reachable: false, reason: "unregistered", latencyMs: latencyMs() };
|
|
48
|
-
}
|
|
49
|
-
const marker = await readJson(transportFile(args.to), null);
|
|
50
|
-
const heartbeatAgeSec = Math.floor((now - entry.lastHeartbeat) / 1000);
|
|
51
|
-
const heartbeatFresh = now - entry.lastHeartbeat < STALE_MS;
|
|
52
|
-
let transportLive = false;
|
|
53
|
-
let paneAlive;
|
|
54
|
-
if (marker) {
|
|
55
|
-
transportLive = isMarkerLive(marker, reg, now);
|
|
56
|
-
if (transportLive && marker.transport === "tmux-push" && marker.tmuxTarget) {
|
|
57
|
-
// The pusher can outlive its pane (agent window closed) — probe the pane.
|
|
58
|
-
// `has-session` VALIDATES THE TARGET; `display-message -p -t <target> "ok"`
|
|
59
|
-
// DOES NOT — tmux exits 0 for any target, including a pane killed a moment
|
|
60
|
-
// ago, so the probe had ZERO discriminating power and reported every dead
|
|
61
|
-
// pane alive. Pinned to the BEHAVIOUR, not a version: measured identical on
|
|
62
|
-
// tmux 3.6b and 3.7b, and a version-pinned claim rots on the next upgrade.
|
|
63
|
-
// Positive control, both directions: bogus target -> has-session exit 1,
|
|
64
|
-
// display-message exit 0; live pane -> both exit 0.
|
|
65
|
-
const probe = spawnSync("tmux", ["has-session", "-t", marker.tmuxTarget]);
|
|
66
|
-
paneAlive = probe.status === 0;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
const reachable = transportLive && paneAlive !== false;
|
|
70
|
-
// heartbeatFresh is only valid EVIDENCE where something writes a heartbeat.
|
|
71
|
-
// A local tmux-push transport's liveness is its pid (isPidAlive) —
|
|
72
|
-
// hooks/tmux-pusher.mjs never calls `heartbeat`, so the field measures time
|
|
73
|
-
// since JOIN, not activity (Task 13.3/13.4, stall.ts's own established
|
|
74
|
-
// rule for exactly this transport type). Crediting it here for a
|
|
75
|
-
// tmux-push agent produced the incident this fix exists for: `alive` and
|
|
76
|
-
// `reachable` read fully healthy, `heartbeatFresh` sat in `checks` reading
|
|
77
|
-
// false, and the top-line boolean never surfaced the disagreement — a
|
|
78
|
-
// status layer discarding what its own lower layer already reported, the
|
|
79
|
-
// same shape as `stall_clock_status` before Task 13.1. A REMOTE pusher, or
|
|
80
|
-
// an agent with no probeable marker at all, has no pid to fall back on —
|
|
81
|
-
// there heartbeat genuinely IS the liveness mechanism, unchanged.
|
|
82
|
-
const heartbeatIsValidSignal = !marker || marker.transport !== "tmux-push";
|
|
83
|
-
const alive = reachable || (heartbeatIsValidSignal && heartbeatFresh);
|
|
84
|
-
let echoSent = false;
|
|
85
|
-
if (args.echo && alive) {
|
|
86
|
-
await sendMessageTool({
|
|
87
|
-
from: args.from,
|
|
88
|
-
to: args.to,
|
|
89
|
-
text: `PING: echo requested by ${args.from} — DM back if responsive.`,
|
|
90
|
-
});
|
|
91
|
-
echoSent = true;
|
|
92
|
-
}
|
|
93
|
-
return {
|
|
94
|
-
ok: true,
|
|
95
|
-
to: args.to,
|
|
96
|
-
alive,
|
|
97
|
-
reachable,
|
|
98
|
-
...(alive ? {} : { reason: marker ? "transport-dead" : "heartbeat-stale" }),
|
|
99
|
-
checks: {
|
|
100
|
-
registered: true,
|
|
101
|
-
heartbeatFresh,
|
|
102
|
-
heartbeatAgeSec,
|
|
103
|
-
// Whether heartbeatFresh above is real evidence for this transport, or
|
|
104
|
-
// just time-since-join. A reader who sees `heartbeatFresh: false` next
|
|
105
|
-
// to `heartbeatValid: false` should not read that as a dissenting
|
|
106
|
-
// signal — there is no signal there to dissent.
|
|
107
|
-
heartbeatValid: heartbeatIsValidSignal,
|
|
108
|
-
transport: marker?.transport ?? null,
|
|
109
|
-
transportLive,
|
|
110
|
-
...(paneAlive !== undefined ? { paneAlive, tmuxTarget: marker?.tmuxTarget } : {}),
|
|
111
|
-
},
|
|
112
|
-
...(args.echo ? { echoSent } : {}),
|
|
113
|
-
latencyMs: latencyMs(),
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
// ---------- send_command (context-management control commands) ----------
|
|
117
|
-
// The only slash commands a lead may inject into a sub-agent's CLI. Locked on
|
|
118
|
-
// purpose: these wipe/compact context or reload the harness's own skill files
|
|
119
|
-
// (cheap, reversible-by-the-agent, harness-local), nothing that mutates the repo
|
|
120
|
-
// or the bus. `reload-skills` added 2026-08-19 on a DAVID_DECISION (option 1:
|
|
121
|
-
// that one command only; re-decide per command). Stored WITHOUT the leading
|
|
122
|
-
// slash; the wire text is `/${cmd}`.
|
|
123
|
-
export const CONTROL_COMMANDS = ["clear", "compact", "reload-skills"];
|
|
124
|
-
// Transports whose pusher can actually TYPE a slash command into a live CLI.
|
|
125
|
-
// A control command is meaningless to a plain MCP poller, so send_command is
|
|
126
|
-
// gated to agents currently attached over one of these.
|
|
127
|
-
const TMUX_TRANSPORTS = new Set(["tmux-push", "tmux-push-remote"]);
|
|
128
|
-
// Normalize "clear" / "/clear" / " /Clear " → "clear"; null if not allowlisted.
|
|
129
|
-
function normalizeControlCommand(raw) {
|
|
130
|
-
const c = raw.trim().replace(/^\/+/, "").toLowerCase();
|
|
131
|
-
return CONTROL_COMMANDS.includes(c) ? c : null;
|
|
132
|
-
}
|
|
133
|
-
// Live transports filtered to the tmux-push family (local + remote).
|
|
134
|
-
async function liveTmuxTargets() {
|
|
135
|
-
const all = await loadLiveTransports();
|
|
136
|
-
const out = new Map();
|
|
137
|
-
for (const [id, m] of all)
|
|
138
|
-
if (TMUX_TRANSPORTS.has(m.transport))
|
|
139
|
-
out.set(id, m);
|
|
140
|
-
return out;
|
|
141
|
-
}
|
|
142
|
-
export const sendCommandSchema = {
|
|
143
|
-
from: z.string().min(1),
|
|
144
|
-
to: z.string().optional(),
|
|
145
|
-
room: z.string().optional(),
|
|
146
|
-
command: z.string().min(1),
|
|
147
|
-
// Default 3000ms. After /clear, schedules an identity-reminder DM to each
|
|
148
|
-
// recipient so a freshly-wiped worker re-anchors on its agentId and bus
|
|
149
|
-
// attach state. Set 0 to opt out. Ignored for non-/clear commands.
|
|
150
|
-
reminderMs: z.number().int().min(0).max(60_000).optional(),
|
|
151
|
-
// Override the auto-generated reminder body if you want something specific.
|
|
152
|
-
reminderText: z.string().optional(),
|
|
153
|
-
// Block until the receiving pusher confirms it actually typed the command
|
|
154
|
-
// into the pane (out-of-band receipt poll — zero added agent context).
|
|
155
|
-
// Default true: a control command you can't confirm is the bug this fixes.
|
|
156
|
-
// Set false for fire-and-forget. The wait is bounded by deliveryTimeoutMs.
|
|
157
|
-
waitForDelivery: z.boolean().optional(),
|
|
158
|
-
deliveryTimeoutMs: z.number().int().min(0).max(30_000).optional(),
|
|
159
|
-
};
|
|
160
|
-
// Poll an agent's receipt log until a receipt for `msgId` appears or the
|
|
161
|
-
// deadline passes. Returns the receipt, or null on timeout. File-only — no
|
|
162
|
-
// agent context.
|
|
163
|
-
//
|
|
164
|
-
// A receipt proves the pusher TYPED the payload into the pane. For a control
|
|
165
|
-
// command that is NOT proof it ran: the command can sit in the input behind an
|
|
166
|
-
// autocomplete menu, delivered and inert. `submitted` is the field that
|
|
167
|
-
// distinguishes them, and `deliveryOutcome` below is the only place allowed to
|
|
168
|
-
// turn a receipt into a "confirmed".
|
|
169
|
-
async function waitForReceipt(agentId, msgId, timeoutMs) {
|
|
170
|
-
const file = receiptFile(agentId);
|
|
171
|
-
const deadline = Date.now() + timeoutMs;
|
|
172
|
-
// First check is immediate; then poll on a short interval.
|
|
173
|
-
for (;;) {
|
|
174
|
-
const receipts = await readJsonl(file);
|
|
175
|
-
const hit = receipts.find((r) => r.id === msgId);
|
|
176
|
-
if (hit)
|
|
177
|
-
return { ...hit, ts: hit.ts ?? Date.now() };
|
|
178
|
-
if (Date.now() >= deadline)
|
|
179
|
-
return null;
|
|
180
|
-
await new Promise((res) => setTimeout(res, 150));
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
// Turn a receipt (or its absence) into the delivery verdict for a CONTROL
|
|
184
|
-
// command. Only `submitted === true` earns "confirmed" — anything else is
|
|
185
|
-
// pending with a reason the caller can act on. Reporting an unverified
|
|
186
|
-
// submission as confirmed is the defect this exists to remove: a check that
|
|
187
|
-
// cannot fail loudly is worse than no check.
|
|
188
|
-
//
|
|
189
|
-
// `pusherSourceMtime` (the caller passes newestPusherSourceMtime()) lets a
|
|
190
|
-
// CONFIRMED verdict carry a note when the reporting pusher's build identity
|
|
191
|
-
// is behind the on-disk pusher source, or absent entirely. The note never
|
|
192
|
-
// downgrades the verdict — the command demonstrably ran — it says whose
|
|
193
|
-
// verification logic said so. Absence of the stamp reads as UNKNOWN, never
|
|
194
|
-
// as fresh (same ruling as doctor's stale-pusher-script / provenance
|
|
195
|
-
// checks: absence is not exemption), and the absence note is issued before
|
|
196
|
-
// the on-disk comparison so an unstattable hooks dir cannot silence it.
|
|
197
|
-
export function deliveryOutcome(agentId, receipt, timeoutMs, pusherSourceMtime) {
|
|
198
|
-
if (!receipt) {
|
|
199
|
-
return {
|
|
200
|
-
delivery: "pending",
|
|
201
|
-
reason: `no delivery receipt from '${agentId}' within ${timeoutMs}ms — the command was written but may not have reached the pane (stale/wedged pusher). Run doctor or re-attach the agent.`,
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
if (receipt.submitted === true) {
|
|
205
|
-
let note;
|
|
206
|
-
if (receipt.scriptMtime === undefined) {
|
|
207
|
-
note = `'${agentId}' confirmed the submission, but its pusher carries no build-identity stamp — the pusher predates receipt provenance and this confirmation cannot be tied to any known code; re-attach the agent (detach_agent + attach_agent) to upgrade it.`;
|
|
208
|
-
}
|
|
209
|
-
else if (pusherSourceMtime !== undefined && receipt.scriptMtime < pusherSourceMtime - 1) {
|
|
210
|
-
const loaded = new Date(receipt.scriptMtime).toISOString();
|
|
211
|
-
const ondisk = new Date(pusherSourceMtime).toISOString();
|
|
212
|
-
note = `'${agentId}' confirmed the submission, but its pusher loaded its code at ${loaded} and the on-disk pusher source is newer (${ondisk}) — the verification logic behind this confirmation predates the current code; re-attach the agent (detach_agent + attach_agent) to upgrade it.`;
|
|
213
|
-
}
|
|
214
|
-
return { delivery: "confirmed", at: receipt.ts, ...(note ? { note } : {}) };
|
|
215
|
-
}
|
|
216
|
-
if (receipt.submitted === false) {
|
|
217
|
-
return {
|
|
218
|
-
delivery: "pending",
|
|
219
|
-
at: receipt.ts,
|
|
220
|
-
reason: receipt.reason ??
|
|
221
|
-
`'${agentId}' pasted the command but could not confirm it was submitted — it may be sitting in the input.`,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
// No `submitted` field: a pre-v0.19.0 pusher that stamps on paste. It may
|
|
225
|
-
// well have worked — but it cannot tell us, and guessing "confirmed" is the
|
|
226
|
-
// lie we are removing. Say what is actually known.
|
|
227
|
-
return {
|
|
228
|
-
delivery: "pending",
|
|
229
|
-
at: receipt.ts,
|
|
230
|
-
reason: `'${agentId}' typed the command into its pane, but its pusher predates submit verification and cannot confirm the command ran — re-attach the agent (detach_agent + attach_agent) to upgrade it.`,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
function defaultReminderText(agentId) {
|
|
234
|
-
return (`[agent-coord] context reset by /clear. ` +
|
|
235
|
-
`Your bus identity is '${agentId}'. You remain registered and attached — call ` +
|
|
236
|
-
`status({agentId:"${agentId}"}) to re-orient (role, transport, unread) and ` +
|
|
237
|
-
`list_rooms() for the channels you're in. Any DM or channel post you receive ` +
|
|
238
|
-
`next is your new task context.`);
|
|
239
|
-
}
|
|
240
|
-
const pendingReminders = new Set();
|
|
241
|
-
/** Reminders whose delivery FAILED, kept so a drop is observable, not stderr-only. */
|
|
242
|
-
export const reminderFailures = [];
|
|
243
|
-
/**
|
|
244
|
-
* Resolves once every scheduled reminder has been delivered or has failed.
|
|
245
|
-
*
|
|
246
|
-
* Re-`ref`s the pending timers for the duration of the wait. The reminder timer
|
|
247
|
-
* is deliberately `unref`d so it never holds the MCP server open on its own —
|
|
248
|
-
* but that also means the event loop will not wait for it, and awaiting an
|
|
249
|
-
* unref'd timer's promise hangs until the loop drains. A caller that has
|
|
250
|
-
* explicitly asked to wait is stating the opposite intent, so the ref is
|
|
251
|
-
* restored for exactly that window and dropped again afterwards.
|
|
252
|
-
*
|
|
253
|
-
* (The unref has a production consequence worth naming: if the server's
|
|
254
|
-
* transport closes before the timer fires, the reminder is dropped. That is a
|
|
255
|
-
* separate defect from the one this function fixes — reported, not silently
|
|
256
|
-
* papered over.)
|
|
257
|
-
*/
|
|
258
|
-
export function remindersSettled() {
|
|
259
|
-
const pending = [...pendingReminders];
|
|
260
|
-
for (const p of pending)
|
|
261
|
-
p.timer.ref();
|
|
262
|
-
return Promise.all(pending.map((p) => p.done)).then(() => {
|
|
263
|
-
for (const p of pending)
|
|
264
|
-
p.timer.unref();
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
function scheduleReminders(from, recipients, delayMs, override) {
|
|
268
|
-
// Registered BEFORE the timer fires, so awaiting settlement covers the delay
|
|
269
|
-
// as well as the write — otherwise a caller could observe "nothing pending"
|
|
270
|
-
// during the window between scheduling and firing.
|
|
271
|
-
let markDone = () => { };
|
|
272
|
-
const done = new Promise((resolve) => { markDone = resolve; });
|
|
273
|
-
let entry;
|
|
274
|
-
const t = setTimeout(async () => {
|
|
275
|
-
try {
|
|
276
|
-
for (const r of recipients) {
|
|
277
|
-
try {
|
|
278
|
-
const reminder = {
|
|
279
|
-
id: randomUUID(),
|
|
280
|
-
ts: Date.now(),
|
|
281
|
-
from,
|
|
282
|
-
to: r,
|
|
283
|
-
text: override ?? defaultReminderText(r),
|
|
284
|
-
// A just-cleared agent is contextless until this lands — it must
|
|
285
|
-
// push immediately, never queue behind the routine tier.
|
|
286
|
-
urgent: true,
|
|
287
|
-
};
|
|
288
|
-
await appendJsonl(inboxFile(r), reminder);
|
|
289
|
-
}
|
|
290
|
-
catch (e) {
|
|
291
|
-
const error = e?.message ?? String(e);
|
|
292
|
-
// Recorded, not just printed: a dropped reminder leaves a just-cleared
|
|
293
|
-
// agent contextless, and stderr is not somewhere anyone looks for that.
|
|
294
|
-
reminderFailures.push({ to: r, error, at: Date.now() });
|
|
295
|
-
process.stderr.write(`[send_command] post-/clear reminder to '${r}' failed: ${error}\n`);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
finally {
|
|
300
|
-
pendingReminders.delete(entry);
|
|
301
|
-
markDone();
|
|
302
|
-
}
|
|
303
|
-
}, delayMs);
|
|
304
|
-
entry = { done, timer: t };
|
|
305
|
-
pendingReminders.add(entry);
|
|
306
|
-
// Don't keep the event loop alive solely for the reminder — the MCP server's
|
|
307
|
-
// transport already holds it open as long as it's connected.
|
|
308
|
-
if (typeof t.unref === "function")
|
|
309
|
-
t.unref();
|
|
310
|
-
}
|
|
311
|
-
// Inject a context-management slash command into a sub-agent's live tmux
|
|
312
|
-
// session. Writes a control-flagged message the pushers deliver RAW (no banner,
|
|
313
|
-
// no `[DM …]` prefix) so the receiving CLI runs it as a real slash command.
|
|
314
|
-
// Hard-gated to tmux: refuses unless the target(s) have a live tmux-push(-remote)
|
|
315
|
-
// transport, so a command never rots unexecuted in an offline inbox.
|
|
316
|
-
export async function sendCommandTool(args) {
|
|
317
|
-
const cmd = normalizeControlCommand(args.command);
|
|
318
|
-
if (!cmd) {
|
|
319
|
-
return {
|
|
320
|
-
ok: false,
|
|
321
|
-
error: `unsupported command '${args.command}'. Allowed: ${CONTROL_COMMANDS.map((c) => "/" + c).join(", ")}`,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
if (!args.to && !args.room) {
|
|
325
|
-
return { ok: false, error: "specify 'to' (a single agent) or 'room' (a channel's tmux-attached members)" };
|
|
326
|
-
}
|
|
327
|
-
if (args.to && args.room) {
|
|
328
|
-
return { ok: false, error: "specify only one of 'to' or 'room'" };
|
|
329
|
-
}
|
|
330
|
-
const text = `/${cmd}`;
|
|
331
|
-
const liveTmux = await liveTmuxTargets();
|
|
332
|
-
// DM: target must itself be tmux-attached.
|
|
333
|
-
if (args.to) {
|
|
334
|
-
const marker = liveTmux.get(args.to);
|
|
335
|
-
if (!marker) {
|
|
336
|
-
return {
|
|
337
|
-
ok: false,
|
|
338
|
-
error: `'${args.to}' has no live tmux-push transport — control commands can only be injected into a tmux session. Attach it (join/attach_agent) or target an attached agent.`,
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
const msg = {
|
|
342
|
-
id: randomUUID(),
|
|
343
|
-
ts: Date.now(),
|
|
344
|
-
from: args.from,
|
|
345
|
-
to: args.to,
|
|
346
|
-
text,
|
|
347
|
-
control: true,
|
|
348
|
-
};
|
|
349
|
-
const target = inboxFile(args.to);
|
|
350
|
-
await appendJsonl(target, msg);
|
|
351
|
-
// Confirm the pusher actually typed it into the pane before we report success
|
|
352
|
-
// (unless explicitly fire-and-forget). Out-of-band receipt poll — the
|
|
353
|
-
// confirmation rides back in THIS tool result, costing no extra agent context.
|
|
354
|
-
const wait = args.waitForDelivery ?? true;
|
|
355
|
-
const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
|
|
356
|
-
const receipt = wait ? await waitForReceipt(args.to, msg.id, deliveryTimeoutMs) : null;
|
|
357
|
-
const outcome = wait ? deliveryOutcome(args.to, receipt, deliveryTimeoutMs, newestPusherSourceMtime()) : null;
|
|
358
|
-
const confirmed = outcome?.delivery === "confirmed";
|
|
359
|
-
// After /clear the receiver forgets its identity and that it's bus-attached
|
|
360
|
-
// (the system prompt isn't re-applied because /clear isn't a session
|
|
361
|
-
// start). Schedule a follow-up DM as a re-anchor; opt out with reminderMs:0.
|
|
362
|
-
const reminderMs = cmd === "clear" ? args.reminderMs ?? 3000 : 0;
|
|
363
|
-
if (reminderMs > 0)
|
|
364
|
-
scheduleReminders(args.from, [args.to], reminderMs, args.reminderText);
|
|
365
|
-
return {
|
|
366
|
-
ok: true,
|
|
367
|
-
id: msg.id,
|
|
368
|
-
command: text,
|
|
369
|
-
target,
|
|
370
|
-
delivered: [args.to],
|
|
371
|
-
transport: marker.transport,
|
|
372
|
-
// delivery: confirmed = pusher typed it into the pane; pending = written but
|
|
373
|
-
// unconfirmed within the timeout (stale/wedged pusher — run doctor). Absent
|
|
374
|
-
// when waitForDelivery:false.
|
|
375
|
-
...(wait && outcome
|
|
376
|
-
? {
|
|
377
|
-
delivery: outcome.delivery,
|
|
378
|
-
confirmed,
|
|
379
|
-
...(outcome.at !== undefined ? { deliveredAt: outcome.at } : {}),
|
|
380
|
-
// A confirmed delivery can still warn: the note names a reporting
|
|
381
|
-
// pusher whose build identity is stale or absent. `confirmed`
|
|
382
|
-
// stays true — the command ran; the warning is about who said so.
|
|
383
|
-
...(confirmed ? (outcome.note ? { warning: outcome.note } : {}) : { warning: outcome.reason }),
|
|
384
|
-
}
|
|
385
|
-
: {}),
|
|
386
|
-
...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: [args.to] } } : {}),
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
// Room: broadcast to every tmux-attached member (never the sender itself).
|
|
390
|
-
const chan = normalizeRoom(args.room);
|
|
391
|
-
const rooms = await getRooms();
|
|
392
|
-
const members = rooms[chan]?.members ?? [];
|
|
393
|
-
const delivered = members.filter((m) => m !== args.from && liveTmux.has(m));
|
|
394
|
-
if (delivered.length === 0) {
|
|
395
|
-
return {
|
|
396
|
-
ok: false,
|
|
397
|
-
error: `no tmux-attached members in #${chan} to receive '${text}' (${members.length} member(s) total). Control commands only fire in a live tmux session.`,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
const skipped = members.filter((m) => m !== args.from && !liveTmux.has(m));
|
|
401
|
-
const msg = {
|
|
402
|
-
id: randomUUID(),
|
|
403
|
-
ts: Date.now(),
|
|
404
|
-
from: args.from,
|
|
405
|
-
room: chan,
|
|
406
|
-
text,
|
|
407
|
-
control: true,
|
|
408
|
-
};
|
|
409
|
-
const target = roomFile(chan);
|
|
410
|
-
await appendJsonl(target, msg);
|
|
411
|
-
// Confirm each member's pusher typed it in (same msg.id lands in every
|
|
412
|
-
// member's own receipt file). Poll all in parallel within one timeout.
|
|
413
|
-
const wait = args.waitForDelivery ?? true;
|
|
414
|
-
const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
|
|
415
|
-
let confirmed = [];
|
|
416
|
-
let pending = [];
|
|
417
|
-
let pendingReasons = [];
|
|
418
|
-
let confirmNotes = [];
|
|
419
|
-
if (wait) {
|
|
420
|
-
const sourceMtime = newestPusherSourceMtime();
|
|
421
|
-
const results = await Promise.all(delivered.map(async (m) => ({
|
|
422
|
-
m,
|
|
423
|
-
outcome: deliveryOutcome(m, await waitForReceipt(m, msg.id, deliveryTimeoutMs), deliveryTimeoutMs, sourceMtime),
|
|
424
|
-
})));
|
|
425
|
-
confirmed = results.filter((r) => r.outcome.delivery === "confirmed").map((r) => r.m);
|
|
426
|
-
pending = results.filter((r) => r.outcome.delivery !== "confirmed").map((r) => r.m);
|
|
427
|
-
pendingReasons = results
|
|
428
|
-
.filter((r) => r.outcome.delivery !== "confirmed")
|
|
429
|
-
.map((r) => `${r.m}: ${r.outcome.reason}`);
|
|
430
|
-
confirmNotes = results
|
|
431
|
-
.filter((r) => r.outcome.delivery === "confirmed" && r.outcome.note)
|
|
432
|
-
.map((r) => r.outcome.note);
|
|
433
|
-
}
|
|
434
|
-
// Same post-/clear re-anchor as the DM path — one reminder per delivered
|
|
435
|
-
// member, in their own inbox, with their own agentId in the body.
|
|
436
|
-
const reminderMs = cmd === "clear" ? args.reminderMs ?? 3000 : 0;
|
|
437
|
-
if (reminderMs > 0)
|
|
438
|
-
scheduleReminders(args.from, delivered, reminderMs, args.reminderText);
|
|
439
|
-
return {
|
|
440
|
-
ok: true,
|
|
441
|
-
id: msg.id,
|
|
442
|
-
command: text,
|
|
443
|
-
target,
|
|
444
|
-
room: chan,
|
|
445
|
-
delivered,
|
|
446
|
-
skipped: skipped.length ? skipped : undefined,
|
|
447
|
-
...(wait
|
|
448
|
-
? {
|
|
449
|
-
delivery: pending.length === 0 ? "confirmed" : "partial",
|
|
450
|
-
confirmed,
|
|
451
|
-
...(pending.length
|
|
452
|
-
? {
|
|
453
|
-
pending,
|
|
454
|
-
warning: `not confirmed as submitted within ${deliveryTimeoutMs}ms — ${pendingReasons.join(" | ")}`,
|
|
455
|
-
}
|
|
456
|
-
: {}),
|
|
457
|
-
// Confirmed members whose reporting pusher is stale or unstamped —
|
|
458
|
-
// the confirmations stand, the notes say whose code issued them.
|
|
459
|
-
...(confirmNotes.length ? { notes: confirmNotes } : {}),
|
|
460
|
-
}
|
|
461
|
-
: {}),
|
|
462
|
-
...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: delivered } } : {}),
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
// Does `pid` actually belong to one of our tmux pushers? A transport marker
|
|
466
|
-
// records a pid, but a marker can outlive its process and pids get recycled —
|
|
467
|
-
// so "pid is alive" is NOT evidence the pid is still the pusher. Anything that
|
|
468
|
-
// SIGTERMs a marker's pid must confirm identity first or it will eventually
|
|
469
|
-
// kill an unrelated process on the user's machine.
|
|
470
|
-
//
|
|
471
|
-
// Pushers are spawned as `<node> <.../hooks/tmux-pusher.mjs>` (see
|
|
472
|
-
// attachAgentTool), so the script path in the process's argv is the signature.
|
|
473
|
-
// Returns false when we cannot confirm — including when `ps` is unavailable.
|
|
474
|
-
// Refusing to kill an unverifiable pid is the safe failure: a wedged pusher
|
|
475
|
-
// that survives is a nuisance, a wrong SIGTERM is not.
|
|
476
|
-
export function isPusherProcess(pid) {
|
|
477
|
-
if (!Number.isInteger(pid) || pid <= 0)
|
|
478
|
-
return false;
|
|
479
|
-
const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" });
|
|
480
|
-
if (ps.status !== 0)
|
|
481
|
-
return false; // pid gone, or no usable ps
|
|
482
|
-
return (ps.stdout ?? "").includes(path.basename(resolvePusherPath()));
|
|
483
|
-
}
|
|
484
|
-
// ---------- attach_agent / detach_agent (tmux push transport) ----------
|
|
485
|
-
export const attachAgentSchema = {
|
|
486
|
-
agentId: z.string().min(1),
|
|
487
|
-
tmuxTarget: z.string().optional(),
|
|
488
|
-
includeRoom: z.boolean().optional(),
|
|
489
|
-
allowlist: z.array(z.string()).optional(),
|
|
490
|
-
debounceMs: z.number().int().positive().max(60_000).optional(),
|
|
491
|
-
};
|
|
492
|
-
export async function attachAgentTool(args) {
|
|
493
|
-
// Resolve target: explicit arg > MCP server's own TMUX_PANE env.
|
|
494
|
-
const target = args.tmuxTarget ?? process.env.TMUX_PANE;
|
|
495
|
-
if (!target) {
|
|
496
|
-
return {
|
|
497
|
-
ok: false,
|
|
498
|
-
error: "tmuxTarget not provided and the MCP server is not running inside tmux (no $TMUX_PANE). Pass tmuxTarget explicitly (e.g. '%42' or 'session:window.pane').",
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
// Validate target exists.
|
|
502
|
-
// `has-session` VALIDATES THE TARGET; `display-message -p -t <target> "ok"`
|
|
503
|
-
// DOES NOT — tmux exits 0 for any target, including a pane killed a moment
|
|
504
|
-
// ago, so the probe had ZERO discriminating power and reported every dead
|
|
505
|
-
// pane alive. Pinned to the BEHAVIOUR, not a version: measured identical on
|
|
506
|
-
// tmux 3.6b and 3.7b, and a version-pinned claim rots on the next upgrade.
|
|
507
|
-
// Positive control, both directions: bogus target -> has-session exit 1,
|
|
508
|
-
// display-message exit 0; live pane -> both exit 0.
|
|
509
|
-
const probe = spawnSync("tmux", ["has-session", "-t", target]);
|
|
510
|
-
if (probe.status !== 0) {
|
|
511
|
-
return {
|
|
512
|
-
ok: false,
|
|
513
|
-
error: `tmux target '${target}' not found: ${(probe.stderr ?? "").toString().trim()}`,
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
// If something's already attached, refuse rather than spawn a second pusher.
|
|
517
|
-
const existing = await readJson(transportFile(args.agentId), null);
|
|
518
|
-
if (existing && isPidAlive(existing.pid)) {
|
|
519
|
-
return {
|
|
520
|
-
ok: false,
|
|
521
|
-
error: `agent '${args.agentId}' already has a live ${existing.transport} attached (pid ${existing.pid}). Call detach_agent first.`,
|
|
522
|
-
existing,
|
|
523
|
-
};
|
|
524
|
-
}
|
|
525
|
-
// Clean up dead marker, if any.
|
|
526
|
-
if (existing)
|
|
527
|
-
await deleteFile(transportFile(args.agentId));
|
|
528
|
-
const pusher = resolvePusherPath();
|
|
529
|
-
if (!existsSync(pusher)) {
|
|
530
|
-
return { ok: false, error: `tmux-pusher not found at ${pusher}` };
|
|
531
|
-
}
|
|
532
|
-
// Detached spawn so the pusher outlives this MCP request/process.
|
|
533
|
-
const log = logFile(args.agentId, "pusher");
|
|
534
|
-
await fsp.mkdir(path.dirname(log), { recursive: true });
|
|
535
|
-
await fsp.mkdir(path.dirname(pidFile(args.agentId, "pusher")), { recursive: true });
|
|
536
|
-
await fsp.mkdir(path.dirname(transportFile(args.agentId)), { recursive: true });
|
|
537
|
-
const logFd = openSync(log, "a");
|
|
538
|
-
// Default: deliver room broadcasts too. The bus is chat-first — silence on
|
|
539
|
-
// a room post is a worse failure mode than a slightly noisier pane. Callers
|
|
540
|
-
// who want DM-only can pass includeRoom:false explicitly.
|
|
541
|
-
const includeRoom = args.includeRoom !== false;
|
|
542
|
-
// Use the exact node binary running this server, not bare "node" — the MCP
|
|
543
|
-
// server is often launched via an absolute path (nvm/Homebrew/bundled
|
|
544
|
-
// runtime) that isn't on the spawned child's PATH, which would silently fail
|
|
545
|
-
// the pusher launch ("attached but nothing arrives").
|
|
546
|
-
// `--agent <id>` is inert to the pusher (env stays authoritative) but puts
|
|
547
|
-
// the agentId in argv, so a pattern kill can be scoped to ONE pusher
|
|
548
|
-
// (`pkill -f "tmux-pusher.mjs --agent <id>"`). Without it the only matchable
|
|
549
|
-
// pattern was the script path, and a `pkill -f tmux-pusher.mjs` during one
|
|
550
|
-
// agent's cleanup silently detached every live agent on the bus (2026-07-28).
|
|
551
|
-
const child = spawn(process.execPath, [pusher, "--agent", args.agentId], {
|
|
552
|
-
detached: true,
|
|
553
|
-
stdio: ["ignore", logFd, logFd],
|
|
554
|
-
env: {
|
|
555
|
-
...process.env,
|
|
556
|
-
AGENT_COORD_ID: args.agentId,
|
|
557
|
-
AGENT_COORD_TMUX_TARGET: target,
|
|
558
|
-
...(includeRoom ? { AGENT_COORD_INCLUDE_ROOM: "1" } : {}),
|
|
559
|
-
...(args.allowlist && args.allowlist.length > 0
|
|
560
|
-
? { AGENT_COORD_ALLOWLIST: args.allowlist.join(",") }
|
|
561
|
-
: {}),
|
|
562
|
-
...(args.debounceMs ? { AGENT_COORD_DEBOUNCE_MS: String(args.debounceMs) } : {}),
|
|
563
|
-
},
|
|
564
|
-
});
|
|
565
|
-
child.unref();
|
|
566
|
-
const pid = child.pid;
|
|
567
|
-
if (!pid)
|
|
568
|
-
return { ok: false, error: "spawn returned no pid" };
|
|
569
|
-
// Write pid file (for scripts) and transport marker (for list_agents).
|
|
570
|
-
await fsp.writeFile(pidFile(args.agentId, "pusher"), String(pid), "utf8");
|
|
571
|
-
// Stamp the pusher source's freshness so doctor() can flag a stale daemon if
|
|
572
|
-
// it outlives a later upgrade of the on-disk code (see v0.8.1 → v0.8.2 bug
|
|
573
|
-
// report: control commands silently dropped by pre-v0.8 in-memory code).
|
|
574
|
-
const scriptMtime = newestPusherSourceMtime();
|
|
575
|
-
const marker = {
|
|
576
|
-
agentId: args.agentId,
|
|
577
|
-
transport: "tmux-push",
|
|
578
|
-
pid,
|
|
579
|
-
tmuxTarget: target,
|
|
580
|
-
since: Date.now(),
|
|
581
|
-
scriptMtime,
|
|
582
|
-
// Provenance: the build identity THIS server loaded at startup — not a
|
|
583
|
-
// fresh stat of dist/, because the code doing the stamping is the loaded
|
|
584
|
-
// code, and after an in-place rebuild the two differ (that difference is
|
|
585
|
-
// exactly what doctor's provenance check exists to surface).
|
|
586
|
-
serverBuildMtime: SERVER_BUILD_MTIME,
|
|
587
|
-
// WHAT this transport carries, recorded by the code that decides it.
|
|
588
|
-
// `includeRoom` is what the pusher is actually spawned with a few lines
|
|
589
|
-
// above, so the marker cannot claim a capability the process was not
|
|
590
|
-
// given — the marker and the spawn come from one value, not two.
|
|
591
|
-
rooms: includeRoom,
|
|
592
|
-
};
|
|
593
|
-
// Use updateJson so it lockfile-protects and creates the file atomically.
|
|
594
|
-
await updateJson(transportFile(args.agentId), marker, () => marker);
|
|
595
|
-
// Best-effort scan for a peek-coord.mjs hook wired to the same agentId —
|
|
596
|
-
// both consumers share the cursor file and would race / double-deliver.
|
|
597
|
-
const conflictingHook = await detectPeekCoordHook(args.agentId);
|
|
598
|
-
return {
|
|
599
|
-
ok: true,
|
|
600
|
-
agentId: args.agentId,
|
|
601
|
-
transport: "tmux-push",
|
|
602
|
-
tmuxTarget: target,
|
|
603
|
-
pid,
|
|
604
|
-
log,
|
|
605
|
-
...(conflictingHook
|
|
606
|
-
? {
|
|
607
|
-
warnings: [
|
|
608
|
-
`peek-coord.mjs hook for agentId='${args.agentId}' detected in ${conflictingHook}. ` +
|
|
609
|
-
`Running both transports causes double-delivery — disable one. ` +
|
|
610
|
-
`Recommend removing the peek-coord hook entry since tmux-push supersedes it.`,
|
|
611
|
-
],
|
|
612
|
-
}
|
|
613
|
-
: {}),
|
|
614
|
-
};
|
|
615
|
-
}
|
|
616
|
-
async function detectPeekCoordHook(agentId) {
|
|
617
|
-
const home = process.env.HOME ?? "";
|
|
618
|
-
const cwd = process.cwd();
|
|
619
|
-
const candidates = [
|
|
620
|
-
path.join(home, ".claude", "settings.json"),
|
|
621
|
-
path.join(home, ".claude", "settings.local.json"),
|
|
622
|
-
path.join(cwd, ".claude", "settings.json"),
|
|
623
|
-
path.join(cwd, ".claude", "settings.local.json"),
|
|
624
|
-
];
|
|
625
|
-
for (const file of candidates) {
|
|
626
|
-
if (!existsSync(file))
|
|
627
|
-
continue;
|
|
628
|
-
try {
|
|
629
|
-
const raw = await fsp.readFile(file, "utf8");
|
|
630
|
-
if (raw.includes("peek-coord.mjs") && raw.includes(`AGENT_COORD_ID=${agentId}`)) {
|
|
631
|
-
return file;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
catch {
|
|
635
|
-
// unreadable, skip
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
return undefined;
|
|
639
|
-
}
|
|
640
|
-
export const detachAgentSchema = {
|
|
641
|
-
agentId: z.string().min(1),
|
|
642
|
-
};
|
|
643
|
-
export async function detachAgentTool(args) {
|
|
644
|
-
const marker = await readJson(transportFile(args.agentId), null);
|
|
645
|
-
let killed = false;
|
|
646
|
-
let unverified = false;
|
|
647
|
-
if (marker && isPidAlive(marker.pid)) {
|
|
648
|
-
// ALIVE IS NOT ENOUGH — VERIFY IT IS A PUSHER BEFORE SIGNALLING IT.
|
|
649
|
-
//
|
|
650
|
-
// A pid is only meaningful while the process that owned it is running: pids
|
|
651
|
-
// are recycled, and a stale marker pointing at a recycled pid makes this a
|
|
652
|
-
// SIGTERM at an unrelated process. Doctor's reaper already gates its kill on
|
|
653
|
-
// `isPusherProcess` for exactly this reason (see the wedged-pusher fix); this
|
|
654
|
-
// path never took the correction, and `unregister`/`quit` inherit it.
|
|
655
|
-
//
|
|
656
|
-
// UNVERIFIABLE MEANS CLEAR THE MARKER, NEVER SIGNAL. The marker is ours to
|
|
657
|
-
// delete and the process is not ours to kill — those are different
|
|
658
|
-
// authorities, and conflating them is how a detach becomes someone else's
|
|
659
|
-
// outage. Reported rather than silent, so a pusher that genuinely needs
|
|
660
|
-
// killing is not left running unnoticed.
|
|
661
|
-
if (isPusherProcess(marker.pid)) {
|
|
662
|
-
try {
|
|
663
|
-
process.kill(marker.pid, "SIGTERM");
|
|
664
|
-
killed = true;
|
|
665
|
-
}
|
|
666
|
-
catch {
|
|
667
|
-
// already gone
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
else {
|
|
671
|
-
unverified = true;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
await deleteFile(transportFile(args.agentId));
|
|
675
|
-
await deleteFile(pidFile(args.agentId, "pusher"));
|
|
676
|
-
return {
|
|
677
|
-
ok: true,
|
|
678
|
-
agentId: args.agentId,
|
|
679
|
-
killed,
|
|
680
|
-
hadMarker: marker !== null,
|
|
681
|
-
...(unverified
|
|
682
|
-
? {
|
|
683
|
-
warning: `pid ${marker.pid} is alive but is NOT a pusher process — the marker was cleared and NOTHING was signalled. ` +
|
|
684
|
-
`A stale marker pointing at a recycled pid would otherwise make this a SIGTERM at an unrelated process. ` +
|
|
685
|
-
`If a pusher is still running for '${args.agentId}', find it with \`doctor\` and let its reaper handle it.`,
|
|
686
|
-
}
|
|
687
|
-
: {}),
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
function resolvePusherPath() {
|
|
691
|
-
// transport.js (compiled) lives in dist/tools/; pusher lives in hooks/ at repo root.
|
|
692
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
693
|
-
return path.resolve(here, "..", "..", "hooks", "tmux-pusher.mjs");
|
|
694
|
-
}
|
|
695
|
-
/** An epoch-ms stamp as ISO-8601 UTC, or an explicit absence — never a blank. */
|
|
696
|
-
function stamp(ms) {
|
|
697
|
-
return ms === undefined ? "(no stamp)" : new Date(ms).toISOString();
|
|
698
|
-
}
|
|
699
|
-
// Freshness basis for the stale-pusher-script mechanism: the newest mtime
|
|
700
|
-
// across the pusher's source dir (hooks/*.mjs), NOT just the entry file. The
|
|
701
|
-
// pusher imports submit.mjs / tier.mjs / roles.mjs, so a fix touching only an
|
|
702
|
-
// import (the #21/#25 control-submit fixes did) leaves tmux-pusher.mjs's own
|
|
703
|
-
// mtime unchanged — a single-file stamp/compare reports ok on a pusher running
|
|
704
|
-
// exactly the code the fix replaced. Used by both the attach-time stamp and
|
|
705
|
-
// doctor's on-disk comparison so the two sides can never drift apart.
|
|
706
|
-
// AGENT_COORD_HOOKS_DIR is a test seam only: it redirects what freshness
|
|
707
|
-
// MEASURES (against a temp copy of hooks/) so tests never touch the mtimes of
|
|
708
|
-
// real sources shared with live pushers — it never changes what attach SPAWNS.
|
|
709
|
-
function newestPusherSourceMtime() {
|
|
710
|
-
const dir = process.env.AGENT_COORD_HOOKS_DIR ?? path.dirname(resolvePusherPath());
|
|
711
|
-
return newestMtimeUnder(dir, [".mjs"]);
|
|
712
|
-
}
|
|
713
|
-
// ---------- status / whoami ----------
|
|
714
|
-
export const statusSchema = { agentId: z.string().min(1) };
|
|
715
|
-
export async function statusTool(args) {
|
|
716
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
717
|
-
const entry = reg[args.agentId];
|
|
718
|
-
const transports = await loadLiveTransports();
|
|
719
|
-
const transport = transports.get(args.agentId);
|
|
720
|
-
const inbox = await readJsonl(inboxFile(args.agentId));
|
|
721
|
-
const cursor = await readJson(cursorFile(args.agentId), {});
|
|
722
|
-
const inboxOffset = cursor.inboxOffset ?? 0;
|
|
723
|
-
const unread = Math.max(0, inbox.length - inboxOffset);
|
|
724
|
-
// WHAT THIS PROCESS CAN ACTUALLY DO, embedded WHOLE.
|
|
725
|
-
//
|
|
726
|
-
// A new tool otherwise lands silently: an agent reading an old listing
|
|
727
|
-
// asserts the tool does not exist, and it is right about its own listing and
|
|
728
|
-
// wrong about the world. Three wrong conclusions in two days came from
|
|
729
|
-
// exactly that, each about which build a pane was running.
|
|
730
|
-
//
|
|
731
|
-
// The full report travels, not a summary. Flattening it to a boolean would
|
|
732
|
-
// hand the caller a tidier answer and a worse one: `versionLabel` stays
|
|
733
|
-
// CONTEXT rather than evidence, a THROWN probe stays an absent capability
|
|
734
|
-
// rather than a broken check, and `answeredBy` keeps naming which pid
|
|
735
|
-
// answered — two servers disagreeing mid-rollout is the normal case, and it
|
|
736
|
-
// is the case every other instrument here reports as if the fleet were one
|
|
737
|
-
// thing.
|
|
738
|
-
const capabilities = await capabilitiesTool();
|
|
739
|
-
return {
|
|
740
|
-
agentId: args.agentId,
|
|
741
|
-
registered: !!entry,
|
|
742
|
-
entry,
|
|
743
|
-
attached: !!transport,
|
|
744
|
-
transport,
|
|
745
|
-
inboxDepth: inbox.length,
|
|
746
|
-
inboxUnread: unread,
|
|
747
|
-
inTmux: !!process.env.TMUX_PANE,
|
|
748
|
-
tmuxPane: process.env.TMUX_PANE,
|
|
749
|
-
capabilities,
|
|
750
|
-
};
|
|
751
|
-
}
|
|
752
|
-
// ---------- join (combo: register + auto-attach + read inbox) ----------
|
|
753
|
-
const joinAttachOptionsSchema = z.object({
|
|
754
|
-
tmuxTarget: z.string().optional(),
|
|
755
|
-
includeRoom: z.boolean().optional(),
|
|
756
|
-
allowlist: z.array(z.string()).optional(),
|
|
757
|
-
debounceMs: z.number().int().positive().max(60_000).optional(),
|
|
758
|
-
});
|
|
759
|
-
export const joinSchema = {
|
|
760
|
-
agentId: z.string().min(1),
|
|
761
|
-
project: z.string().optional(),
|
|
762
|
-
// Free text, or a declared identity ({roleId, displayName}) — see
|
|
763
|
-
// roleInputSchema. A frozen roleId cannot be changed by re-joining.
|
|
764
|
-
role: roleInputSchema.optional(),
|
|
765
|
-
// attach: undefined → auto-attach if $TMUX_PANE is set; true → always try;
|
|
766
|
-
// false → never; object → attach with overrides.
|
|
767
|
-
attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
|
|
768
|
-
readInbox: z.boolean().optional(),
|
|
769
|
-
// First-claim guard overrides (server.ts guardFirstClaim): claiming an id
|
|
770
|
-
// that is LIVE on the bus refuses unless the call presents that agent's
|
|
771
|
-
// token (tokens.json / coord-token) or force:true. Ignored once bound.
|
|
772
|
-
token: z.string().optional(),
|
|
773
|
-
force: z.boolean().optional(),
|
|
774
|
-
// Prose-only exemption from the typed-record rule — see registerSchema.
|
|
775
|
-
// Declared here too because `join` is the call every card actually makes.
|
|
776
|
-
proseOnly: z.union([z.boolean(), z.object({ reason: z.string().min(1) })]).optional(),
|
|
777
|
-
};
|
|
778
|
-
export async function joinTool(args) {
|
|
779
|
-
const reg = await registerTool({
|
|
780
|
-
agentId: args.agentId,
|
|
781
|
-
project: args.project,
|
|
782
|
-
role: args.role,
|
|
783
|
-
proseOnly: args.proseOnly,
|
|
784
|
-
});
|
|
785
|
-
// A refused role update (frozen roleId) fails the whole join rather than
|
|
786
|
-
// silently attaching a transport under the wrong identity.
|
|
787
|
-
if (!reg.ok)
|
|
788
|
-
return reg;
|
|
789
|
-
// Decide attach behavior.
|
|
790
|
-
const wantAttach = args.attach === false
|
|
791
|
-
? false
|
|
792
|
-
: args.attach === true || typeof args.attach === "object"
|
|
793
|
-
? true
|
|
794
|
-
: !!process.env.TMUX_PANE; // undefined → auto-detect
|
|
795
|
-
// Always present as object | null so callers can branch on a single key
|
|
796
|
-
// instead of "did I pass attach?" — per agent-pa's API review.
|
|
797
|
-
let attach = null;
|
|
798
|
-
if (wantAttach) {
|
|
799
|
-
const opts = typeof args.attach === "object" ? args.attach : {};
|
|
800
|
-
attach = await attachAgentTool({ agentId: args.agentId, ...opts });
|
|
801
|
-
}
|
|
802
|
-
const readInbox = args.readInbox ?? true;
|
|
803
|
-
let inbox = null;
|
|
804
|
-
if (readInbox) {
|
|
805
|
-
inbox = await readMessagesTool({ agentId: args.agentId, source: "inbox" });
|
|
806
|
-
}
|
|
807
|
-
// Surface the default channel's topic + MOTD (room rules) in the same
|
|
808
|
-
// round-trip, so a connecting agent sees them without a separate call.
|
|
809
|
-
const rooms = await getRooms();
|
|
810
|
-
const def = rooms[DEFAULT_ROOM];
|
|
811
|
-
return {
|
|
812
|
-
ok: true,
|
|
813
|
-
registered: reg.agent,
|
|
814
|
-
attached: !!attach && attach.ok !== false,
|
|
815
|
-
attach,
|
|
816
|
-
inbox,
|
|
817
|
-
defaultRoom: { room: DEFAULT_ROOM, topic: def?.topic, motd: def?.motd },
|
|
818
|
-
inTmux: !!process.env.TMUX_PANE,
|
|
819
|
-
};
|
|
820
|
-
}
|
|
821
|
-
// ---------- transport markers (for remote pushers) ----------
|
|
822
|
-
export const reportTransportSchema = {
|
|
823
|
-
agentId: z.string().min(1),
|
|
824
|
-
transport: z.string().min(1),
|
|
825
|
-
tmuxTarget: z.string().optional(),
|
|
826
|
-
host: z.string().optional(),
|
|
827
|
-
since: z.number().optional(),
|
|
828
|
-
// mtime of the script the remote daemon loaded into memory at spawn time
|
|
829
|
-
// (epoch ms). Lets doctor() flag the remote pusher as stale if its on-disk
|
|
830
|
-
// counterpart has been upgraded since. The remote pusher passes
|
|
831
|
-
// `(await fsp.stat(__filename)).mtimeMs`; absent → doctor skips the check.
|
|
832
|
-
scriptMtime: z.number().optional(),
|
|
833
|
-
// Whether this pusher carries room traffic as well as DMs. Absent → the
|
|
834
|
-
// marker cannot say, and every reader reports UNKNOWN rather than assuming
|
|
835
|
-
// a full transport (the worker-2 shape).
|
|
836
|
-
rooms: z.boolean().optional(),
|
|
837
|
-
};
|
|
838
|
-
// Called by an external push daemon (typically scripts/coord-pusher.mjs on a
|
|
839
|
-
// remote machine) to publish a transport marker so list_agents reflects the
|
|
840
|
-
// attachment. The local tmux-push path writes the marker directly inside
|
|
841
|
-
// attach_agent; this is the wire-callable equivalent for remote pushers.
|
|
842
|
-
export async function reportTransportTool(args) {
|
|
843
|
-
const marker = {
|
|
844
|
-
agentId: args.agentId,
|
|
845
|
-
transport: args.transport,
|
|
846
|
-
pid: 0, // not meaningful for remote; liveness comes from heartbeat
|
|
847
|
-
tmuxTarget: args.tmuxTarget,
|
|
848
|
-
host: args.host,
|
|
849
|
-
since: args.since ?? Date.now(),
|
|
850
|
-
scriptMtime: args.scriptMtime,
|
|
851
|
-
rooms: args.rooms,
|
|
852
|
-
};
|
|
853
|
-
await updateJson(transportFile(args.agentId), marker, () => marker);
|
|
854
|
-
return { ok: true, marker };
|
|
855
|
-
}
|
|
856
|
-
export const clearTransportSchema = {
|
|
857
|
-
agentId: z.string().min(1),
|
|
858
|
-
};
|
|
859
|
-
// Idempotent remote-counterpart to detach_agent: just deletes the marker. Used
|
|
860
|
-
// by the remote pusher on graceful shutdown so list_agents stops showing it
|
|
861
|
-
// attached. (Does NOT try to kill any process — there's nothing local to kill.)
|
|
862
|
-
export async function clearTransportTool(args) {
|
|
863
|
-
const removed = await deleteFile(transportFile(args.agentId));
|
|
864
|
-
return { ok: true, removed };
|
|
865
|
-
}
|
|
866
|
-
export const reportReceiptSchema = {
|
|
867
|
-
agentId: z.string().min(1),
|
|
868
|
-
id: z.string().min(1),
|
|
869
|
-
from: z.string().optional(),
|
|
870
|
-
control: z.boolean().optional(),
|
|
871
|
-
submitted: z.boolean().optional(),
|
|
872
|
-
verified: z.boolean().optional(),
|
|
873
|
-
reason: z.string().optional(),
|
|
874
|
-
// Build identity of the reporting pusher: newest mtime across its loaded
|
|
875
|
-
// module graph (entry file + hooks/ imports), sampled once at its startup —
|
|
876
|
-
// the same basis report_transport's scriptMtime uses. Absent → the receipt's
|
|
877
|
-
// provenance is UNKNOWN and deliveryOutcome says so; the server never
|
|
878
|
-
// defaults it (a default here would be assume-fresh, the twin of the
|
|
879
|
-
// assume-success `submitted` refuses to invent).
|
|
880
|
-
scriptMtime: z.number().optional(),
|
|
881
|
-
};
|
|
882
|
-
// Wire-callable counterpart to the local pusher's receipt stamp (writeReceipts
|
|
883
|
-
// in hooks/tmux-pusher.mjs). A remote pusher types into a pane on ANOTHER
|
|
884
|
-
// machine and cannot append to this host's receipts/<id>.jsonl, so before this
|
|
885
|
-
// existed a control command to a tmux-push-remote agent was never confirmable:
|
|
886
|
-
// send_command waited out deliveryTimeoutMs and reported delivery:"pending"
|
|
887
|
-
// even when the command demonstrably ran.
|
|
888
|
-
//
|
|
889
|
-
// The receipt is appended in the exact shape the local pusher writes, so
|
|
890
|
-
// waitForReceipt/deliveryOutcome need no remote-specific branch. `submitted`
|
|
891
|
-
// is recorded only when the caller reports it — absence means "typed but
|
|
892
|
-
// unverified", which deliveryOutcome refuses to call confirmed. The server
|
|
893
|
-
// cannot see the remote pane, so it stores what the pusher observed and
|
|
894
|
-
// nothing more; defaulting the field here would recreate assume-success one
|
|
895
|
-
// layer up. Trust matches report_transport: the identity gate binds agentId
|
|
896
|
-
// to the session, so a pusher can only stamp its own agent's receipt file.
|
|
897
|
-
export async function reportReceiptTool(args) {
|
|
898
|
-
const receipt = {
|
|
899
|
-
id: args.id,
|
|
900
|
-
agentId: args.agentId,
|
|
901
|
-
ts: Date.now(),
|
|
902
|
-
...(args.from !== undefined ? { from: args.from } : {}),
|
|
903
|
-
control: args.control === true,
|
|
904
|
-
...(args.submitted !== undefined ? { submitted: args.submitted } : {}),
|
|
905
|
-
...(args.verified !== undefined ? { verified: args.verified } : {}),
|
|
906
|
-
...(args.reason !== undefined ? { reason: args.reason } : {}),
|
|
907
|
-
...(args.scriptMtime !== undefined ? { scriptMtime: args.scriptMtime } : {}),
|
|
908
|
-
};
|
|
909
|
-
await appendJsonl(receiptFile(args.agentId), receipt);
|
|
910
|
-
return { ok: true, receipt };
|
|
911
|
-
}
|
|
912
|
-
// Count non-empty lines vs successfully-parsed entries in a JSONL file.
|
|
913
|
-
// Offsets index the PARSED entries (see readJsonl), so `parsed` is the figure
|
|
914
|
-
// cursor math is compared against; `malformed` is the desync risk.
|
|
915
|
-
async function scanJsonl(file) {
|
|
916
|
-
if (!existsSync(file))
|
|
917
|
-
return { lines: 0, parsed: 0, malformed: 0 };
|
|
918
|
-
const raw = await fsp.readFile(file, "utf8");
|
|
919
|
-
let lines = 0;
|
|
920
|
-
let parsed = 0;
|
|
921
|
-
for (const line of raw.split("\n")) {
|
|
922
|
-
if (!line.trim())
|
|
923
|
-
continue;
|
|
924
|
-
lines++;
|
|
925
|
-
try {
|
|
926
|
-
JSON.parse(line);
|
|
927
|
-
parsed++;
|
|
928
|
-
}
|
|
929
|
-
catch {
|
|
930
|
-
// malformed
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
return { lines, parsed, malformed: lines - parsed };
|
|
934
|
-
}
|
|
935
|
-
// Find leftover proper-lockfile lock dirs (`<file>.lock`) across the state
|
|
936
|
-
// dirs. Anything older than the threshold is almost certainly orphaned by a
|
|
937
|
-
// crashed writer (withLock's stale window is 5s).
|
|
938
|
-
async function scanStaleLocks(olderThanMs, now) {
|
|
939
|
-
const out = [];
|
|
940
|
-
const dirs = [ROOT, INBOX_DIR, CURSOR_DIR, ROOMS_DIR, TRANSPORT_DIR];
|
|
941
|
-
for (const dir of dirs) {
|
|
942
|
-
if (!existsSync(dir))
|
|
943
|
-
continue;
|
|
944
|
-
let names;
|
|
945
|
-
try {
|
|
946
|
-
names = await fsp.readdir(dir);
|
|
947
|
-
}
|
|
948
|
-
catch {
|
|
949
|
-
continue;
|
|
950
|
-
}
|
|
951
|
-
for (const name of names) {
|
|
952
|
-
if (!name.endsWith(".lock"))
|
|
953
|
-
continue;
|
|
954
|
-
const p = path.join(dir, name);
|
|
955
|
-
try {
|
|
956
|
-
const st = await fsp.stat(p);
|
|
957
|
-
const ageMs = now - st.mtimeMs;
|
|
958
|
-
if (ageMs > olderThanMs)
|
|
959
|
-
out.push({ path: p, ageMs });
|
|
960
|
-
}
|
|
961
|
-
catch {
|
|
962
|
-
// vanished mid-scan
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
return out;
|
|
967
|
-
}
|
|
968
|
-
export const doctorSchema = {
|
|
969
|
-
fix: z.boolean().optional(),
|
|
970
|
-
maxFileBytes: z.number().int().positive().optional(),
|
|
971
|
-
};
|
|
972
|
-
export async function doctorTool(args) {
|
|
973
|
-
const fix = args.fix ?? false;
|
|
974
|
-
const maxBytes = args.maxFileBytes ?? 5 * 1024 * 1024;
|
|
975
|
-
const now = Date.now();
|
|
976
|
-
const findings = [];
|
|
977
|
-
const fixed = [];
|
|
978
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
979
|
-
const known = new Set(Object.keys(reg));
|
|
980
|
-
const rooms = await getRooms();
|
|
981
|
-
const channels = Object.keys(rooms);
|
|
982
|
-
// 1. Orphan transport markers (dead local pid, or stale remote heartbeat).
|
|
983
|
-
{
|
|
984
|
-
const dead = [];
|
|
985
|
-
for (const fname of await listTransportFiles()) {
|
|
986
|
-
const file = path.join(TRANSPORT_DIR, fname);
|
|
987
|
-
const marker = await readJson(file, null);
|
|
988
|
-
if (!marker || !isMarkerLive(marker, reg, now)) {
|
|
989
|
-
dead.push(file);
|
|
990
|
-
if (fix) {
|
|
991
|
-
await deleteFile(file);
|
|
992
|
-
fixed.push(`deleted stale transport marker ${fname}`);
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
findings.push({
|
|
997
|
-
check: "orphan-transport-markers",
|
|
998
|
-
level: dead.length ? "warn" : "ok",
|
|
999
|
-
detail: dead.length ? `${dead.length} stale transport marker(s) (dead pid or expired remote heartbeat)` : "no stale transport markers",
|
|
1000
|
-
fixable: true,
|
|
1001
|
-
items: dead.length ? dead.map((f) => path.basename(f)) : undefined,
|
|
1002
|
-
});
|
|
1003
|
-
}
|
|
1004
|
-
// 1b. Stale pusher daemons — a long-running pusher loaded its script into
|
|
1005
|
-
// memory at spawn time, so when the on-disk script is later upgraded
|
|
1006
|
-
// (npm i -g a new version), the still-running pid is on the OLD code.
|
|
1007
|
-
// Pre-v0.8.2 pushers had no `control:true` awareness and silently
|
|
1008
|
-
// dropped /clear /compact at the slash-guard — ack:true with no
|
|
1009
|
-
// keystrokes ever reaching the pane. Comparing the marker's stamped
|
|
1010
|
-
// scriptMtime against the newest on-disk mtime across hooks/*.mjs
|
|
1011
|
-
// catches it — the whole module graph, not just the entry file, because
|
|
1012
|
-
// the control-submit logic lives in submit.mjs and a fix landing there
|
|
1013
|
-
// alone leaves tmux-pusher.mjs's mtime (and a single-file stamp) intact.
|
|
1014
|
-
// Local tmux-push only — for tmux-push-remote the script lives on a
|
|
1015
|
-
// different host so we can't stat it from here.
|
|
1016
|
-
{
|
|
1017
|
-
const localPusherMtime = newestPusherSourceMtime();
|
|
1018
|
-
const stale = [];
|
|
1019
|
-
const unverifiable = [];
|
|
1020
|
-
for (const fname of await listTransportFiles()) {
|
|
1021
|
-
const file = path.join(TRANSPORT_DIR, fname);
|
|
1022
|
-
const marker = await readJson(file, null);
|
|
1023
|
-
if (!marker || !isMarkerLive(marker, reg, now))
|
|
1024
|
-
continue;
|
|
1025
|
-
if (marker.transport !== "tmux-push")
|
|
1026
|
-
continue; // remote = can't verify (documented limit: can't stat another host)
|
|
1027
|
-
if (marker.scriptMtime === undefined) {
|
|
1028
|
-
// ABSENCE IS NOT EXEMPTION. The field's own writer once dropped it,
|
|
1029
|
-
// and the silent skip here meant the check was disabled by the very
|
|
1030
|
-
// thing it monitors — a live pusher we cannot verify is a warn, not
|
|
1031
|
-
// an ok (credit agent-coordination-david-dev). Same flip, same
|
|
1032
|
-
// commit, as serverBuildMtime below: the two checks must never
|
|
1033
|
-
// disagree about what absence means.
|
|
1034
|
-
unverifiable.push(`${marker.agentId} (pid ${marker.pid}, no scriptMtime stamp — cannot verify; detach_agent + attach_agent to re-stamp)`);
|
|
1035
|
-
continue;
|
|
1036
|
-
}
|
|
1037
|
-
if (localPusherMtime === undefined)
|
|
1038
|
-
continue;
|
|
1039
|
-
if (marker.scriptMtime < localPusherMtime - 1) { // -1ms slack for fs mtime rounding
|
|
1040
|
-
const loaded = new Date(marker.scriptMtime).toISOString();
|
|
1041
|
-
const ondisk = new Date(localPusherMtime).toISOString();
|
|
1042
|
-
stale.push(`${marker.agentId} (pid ${marker.pid}, loaded ${loaded}, on-disk now ${ondisk})`);
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
const bad = [...stale, ...unverifiable];
|
|
1046
|
-
findings.push({
|
|
1047
|
-
check: "stale-pusher-script",
|
|
1048
|
-
level: bad.length ? "warn" : "ok",
|
|
1049
|
-
detail: bad.length
|
|
1050
|
-
? `${stale.length} attached pusher(s) running pre-upgrade code and ${unverifiable.length} whose freshness cannot be verified (no stamp) — control commands (/clear, /compact) may be silently dropped. Run detach_agent + attach_agent for each, or have the agent relaunch.`
|
|
1051
|
-
: "all attached pushers are running the current on-disk script",
|
|
1052
|
-
fixable: false,
|
|
1053
|
-
items: bad.length ? bad : undefined,
|
|
1054
|
-
});
|
|
1055
|
-
}
|
|
1056
|
-
// 1b². The same staleness class one layer up: doctor itself runs inside an
|
|
1057
|
-
// MCP server process that imported dist/ at startup. `npm run build`
|
|
1058
|
-
// rewrites dist/ under the still-running server, which then keeps
|
|
1059
|
-
// spawning pushers and stamping markers with logic the rebuild
|
|
1060
|
-
// replaced — merging is not deploying, and until the session restarts
|
|
1061
|
-
// no on-disk artifact reflects what this process will actually do.
|
|
1062
|
-
// Self-scoped by construction: each session's doctor reports on the
|
|
1063
|
-
// server it is running in, which is the only process whose loaded
|
|
1064
|
-
// build it can truthfully know.
|
|
1065
|
-
{
|
|
1066
|
-
const onDisk = onDiskBuildMtime();
|
|
1067
|
-
const drifted = SERVER_BUILD_MTIME !== undefined && onDisk !== undefined && SERVER_BUILD_MTIME < onDisk - 1;
|
|
1068
|
-
const identity = `${SERVER_BUILD_SHA ?? "unknown-sha"} @ ${BUILD_DIR}`;
|
|
1069
|
-
findings.push({
|
|
1070
|
-
check: "server-build-drift",
|
|
1071
|
-
level: drifted ? "warn" : "ok",
|
|
1072
|
-
detail: drifted
|
|
1073
|
-
? `this MCP server loaded its build at ${new Date(SERVER_BUILD_MTIME).toISOString()} but the on-disk build is newer (${new Date(onDisk).toISOString()}) — the session is running pre-rebuild code and everything it stamps or spawns uses replaced logic. Restart this agent's session. (${identity})`
|
|
1074
|
-
: `server is running the current on-disk build (${identity})`,
|
|
1075
|
-
fixable: false,
|
|
1076
|
-
});
|
|
1077
|
-
}
|
|
1078
|
-
// 1b³. WHERE A NEW GLOBAL INSTALL WOULD LAND, which is a different question
|
|
1079
|
-
// from which copy is running (1b² above) and neither substitutes. On
|
|
1080
|
-
// this box `npm prefix -g` and the fleet's actual load path pointed at
|
|
1081
|
-
// two different nvm versions, so an install that printed success would
|
|
1082
|
-
// have updated a copy nothing loads.
|
|
1083
|
-
{
|
|
1084
|
-
const loadPrefix = prefixOf(BUILD_DIR);
|
|
1085
|
-
let installPrefix = null;
|
|
1086
|
-
let npmPath;
|
|
1087
|
-
try {
|
|
1088
|
-
installPrefix = execFileSync("npm", ["prefix", "-g"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
1089
|
-
npmPath = execFileSync("which", ["npm"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
1090
|
-
}
|
|
1091
|
-
catch {
|
|
1092
|
-
installPrefix = null;
|
|
1093
|
-
}
|
|
1094
|
-
const v = prefixVerdict(loadPrefix, installPrefix, npmPath);
|
|
1095
|
-
findings.push({ check: "install-prefix", level: v.level, detail: v.detail, fixable: false });
|
|
1096
|
-
}
|
|
1097
|
-
// 1b²ᵇ. The affirmative catch for merged-but-never-rebuilt: src/ newer than
|
|
1098
|
-
// the compiled build means no restart can help — the artifact every
|
|
1099
|
-
// future session will load is already behind the code. Distinct from
|
|
1100
|
-
// 1b² (a process behind its dist); this is the DISK being behind
|
|
1101
|
-
// itself, which is why it can fire on a bus with zero live sessions.
|
|
1102
|
-
// Not inferred from marker state: both sides are statted directly.
|
|
1103
|
-
{
|
|
1104
|
-
const srcMtime = onDiskSourceMtime();
|
|
1105
|
-
const distMtime = onDiskBuildMtime();
|
|
1106
|
-
const behind = srcMtime !== undefined && distMtime !== undefined && distMtime < srcMtime - 1;
|
|
1107
|
-
findings.push({
|
|
1108
|
-
check: "dist-behind-source",
|
|
1109
|
-
level: behind ? "warn" : "ok",
|
|
1110
|
-
detail: behind
|
|
1111
|
-
? `src/ is newer than the compiled build (src ${new Date(srcMtime).toISOString()}, dist ${new Date(distMtime).toISOString()}) — the checkout was updated but never rebuilt, so every session (current and future) runs pre-update code. \`npm run build\`, then restart sessions.`
|
|
1112
|
-
: srcMtime === undefined
|
|
1113
|
-
? "no src/ to compare (packaged install) — dist is the only artifact"
|
|
1114
|
-
: "compiled build is at least as new as src/",
|
|
1115
|
-
fixable: false,
|
|
1116
|
-
});
|
|
1117
|
-
}
|
|
1118
|
-
// 1b³. Marker provenance — which server BUILD stamped each marker. A live
|
|
1119
|
-
// pusher can be perfectly fresh while the marker's stamps were
|
|
1120
|
-
// computed by an outdated server (observed live 2026-07-29: a stale
|
|
1121
|
-
// server's attach stamped single-file freshness that agreed with the
|
|
1122
|
-
// new on-disk check only because tmux-pusher.mjs happened to be the
|
|
1123
|
-
// newest hooks file). Local tmux-push only, same as 1b.
|
|
1124
|
-
{
|
|
1125
|
-
const onDisk = onDiskBuildMtime();
|
|
1126
|
-
const unverifiable = [];
|
|
1127
|
-
for (const fname of await listTransportFiles()) {
|
|
1128
|
-
const file = path.join(TRANSPORT_DIR, fname);
|
|
1129
|
-
const marker = await readJson(file, null);
|
|
1130
|
-
if (!marker || !isMarkerLive(marker, reg, now))
|
|
1131
|
-
continue;
|
|
1132
|
-
if (marker.transport !== "tmux-push")
|
|
1133
|
-
continue; // remote = can't verify (documented limit: can't stat another host)
|
|
1134
|
-
if (marker.serverBuildMtime === undefined) {
|
|
1135
|
-
// ABSENCE IS NOT EXEMPTION — flipped in the same commit as the
|
|
1136
|
-
// scriptMtime absence above, so the two checks can never disagree
|
|
1137
|
-
// about what a missing stamp means. An unstamped marker was written
|
|
1138
|
-
// by a pre-provenance server (or by hand): precisely the population
|
|
1139
|
-
// this check exists to police, and the one it must not exempt.
|
|
1140
|
-
// Transition is deliberately correct-and-loud: after the upgrade,
|
|
1141
|
-
// every pre-existing marker warns at once, each cleared by a session
|
|
1142
|
-
// restart + re-attach — the burst is also the only external view of
|
|
1143
|
-
// which sessions still run pre-provenance servers, since a stale
|
|
1144
|
-
// server cannot self-report (see 1b²).
|
|
1145
|
-
unverifiable.push(`${marker.agentId} (no serverBuildMtime stamp — stamped by a pre-provenance server; restart that session, then detach_agent + attach_agent)`);
|
|
1146
|
-
continue;
|
|
1147
|
-
}
|
|
1148
|
-
if (onDisk === undefined)
|
|
1149
|
-
continue;
|
|
1150
|
-
if (marker.serverBuildMtime < onDisk - 1) {
|
|
1151
|
-
// A COMPONENT CAN REPORT ITS OWN FRESHNESS; A SIBLING'S RECORD OF IT
|
|
1152
|
-
// GOES STALE SILENTLY. That asymmetry is the whole correction here.
|
|
1153
|
-
//
|
|
1154
|
-
// `serverBuildMtime` is stamped inside `attach_agent` — by the PUSHER's
|
|
1155
|
-
// attach, once — so it records what the server was AT THE LAST ATTACH.
|
|
1156
|
-
// A server-only restart (David's `/mcp`) reloads the server and touches
|
|
1157
|
-
// no marker, so this stamp cannot be refreshed by the thing it
|
|
1158
|
-
// describes and CANNOT DISTINGUISH a restarted server from an
|
|
1159
|
-
// un-restarted one.
|
|
1160
|
-
//
|
|
1161
|
-
// Measured on three agents: each answered `list_subscriptions` — a tool
|
|
1162
|
-
// that does not exist before 0.26.7 — while its marker still stamped a
|
|
1163
|
-
// pre-0.26.7 build. The stamp was 2.2 hours behind the installed dist
|
|
1164
|
-
// on a server demonstrably running the new code.
|
|
1165
|
-
//
|
|
1166
|
-
// So this is reported as what it IS: the marker predates the build, and
|
|
1167
|
-
// the server's actual state is UNKNOWN from here. Calling it an
|
|
1168
|
-
// outdated server is a claim this record cannot support, and it priced
|
|
1169
|
-
// a restart decision on a count where at least three entries had
|
|
1170
|
-
// already restarted.
|
|
1171
|
-
//
|
|
1172
|
-
// The pusher half is untouched and remains valid: `scriptMtime` is
|
|
1173
|
-
// self-reported by the process it describes.
|
|
1174
|
-
const stamped = new Date(marker.serverBuildMtime).toISOString();
|
|
1175
|
-
const current = new Date(onDisk).toISOString();
|
|
1176
|
-
unverifiable.push(`${marker.agentId} (marker stamped at the last ATTACH from server build ${stamped}, on-disk build ${current} — this says the MARKER is behind, NOT that the server is: a server-only restart refreshes no marker. Exercise the server to settle it, or detach_agent + attach_agent to refresh the stamp)`);
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
// `outdated` is gone, not emptied: nothing can populate it any more, and a
|
|
1180
|
-
// bucket that always reports 0 is a claim the reader still has to discount.
|
|
1181
|
-
const bad = unverifiable;
|
|
1182
|
-
findings.push({
|
|
1183
|
-
check: "marker-server-provenance",
|
|
1184
|
-
level: bad.length ? "warn" : "ok",
|
|
1185
|
-
detail: bad.length
|
|
1186
|
-
? `${bad.length} transport marker(s) cannot report their server's build: the stamp is written by attach_agent, so it records the server AT THE LAST ATTACH and a server-only restart refreshes nothing. This says the MARKER is behind or absent — NOT that the server is stale. Exercise the server to settle it (a tool that exists only in the newer build), or detach_agent + attach_agent to refresh the stamp. Restart the session too if the server itself is old.`
|
|
1187
|
-
: "every local transport marker was stamped at an attach against the current build — which says the markers are current, not that every server is",
|
|
1188
|
-
fixable: false,
|
|
1189
|
-
items: bad.length ? bad : undefined,
|
|
1190
|
-
});
|
|
1191
|
-
}
|
|
1192
|
-
// 1b³. THE SPLIT: one pane, two components, ONE version number.
|
|
1193
|
-
//
|
|
1194
|
-
// The two stamps above are each checked, and each in its own finding — which
|
|
1195
|
-
// means the state that actually bit us has no name. `detach_agent` +
|
|
1196
|
-
// `attach_agent` respawns the PUSHER and reloads its script; **it does not
|
|
1197
|
-
// restart the MCP SERVER.** So a pane can run a current pusher against a
|
|
1198
|
-
// two-day-old server, and both existing checks are individually correct while
|
|
1199
|
-
// nothing says "this agent is split". Measured across this machine: all three
|
|
1200
|
-
// panes ran a 2026-08-27 pusher hook against a 2026-08-25 server build.
|
|
1201
|
-
//
|
|
1202
|
-
// WHY IT IS A DISPLAY TASK AND NOT A DESIGN ONE: both facts were already
|
|
1203
|
-
// written into every marker. Nothing needed adding; the pair needed reading
|
|
1204
|
-
// together, and reading them apart is what let "we restarted the transports"
|
|
1205
|
-
// be heard as "we restarted the fleet".
|
|
1206
|
-
//
|
|
1207
|
-
// The classification is the deliverable, because the REMEDIES differ: SPLIT
|
|
1208
|
-
// needs a session restart (re-attach will not touch the server), fully-stale
|
|
1209
|
-
// needs both, fresh needs nothing. Reporting two independent warnings leaves
|
|
1210
|
-
// the reader to infer which — and the inference that was actually made was the
|
|
1211
|
-
// optimistic one.
|
|
1212
|
-
//
|
|
1213
|
-
// Every timestamp is ISO-8601 UTC (`Z`), so no reading depends on the reader's
|
|
1214
|
-
// timezone.
|
|
1215
|
-
{
|
|
1216
|
-
const onDiskScript = newestPusherSourceMtime();
|
|
1217
|
-
const onDiskServer = onDiskBuildMtime();
|
|
1218
|
-
const split = [];
|
|
1219
|
-
const bothStale = [];
|
|
1220
|
-
const unknown = [];
|
|
1221
|
-
let fresh = 0;
|
|
1222
|
-
for (const fname of await listTransportFiles()) {
|
|
1223
|
-
const file = path.join(TRANSPORT_DIR, fname);
|
|
1224
|
-
const marker = await readJson(file, null);
|
|
1225
|
-
if (!marker || !isMarkerLive(marker, reg, now))
|
|
1226
|
-
continue;
|
|
1227
|
-
if (marker.transport !== "tmux-push")
|
|
1228
|
-
continue; // remote: the script lives on another host
|
|
1229
|
-
const { scriptMtime, serverBuildMtime, agentId } = marker;
|
|
1230
|
-
if (scriptMtime === undefined || serverBuildMtime === undefined || onDiskScript === undefined || onDiskServer === undefined) {
|
|
1231
|
-
// A pane missing either stamp cannot be classified. Saying so is the
|
|
1232
|
-
// point: "unclassifiable" and "fresh" are different answers, and the
|
|
1233
|
-
// whole finding is about not collapsing them.
|
|
1234
|
-
unknown.push(`${agentId} (pusher ${stamp(scriptMtime)} · server ${stamp(serverBuildMtime)} — one stamp missing, cannot classify)`);
|
|
1235
|
-
continue;
|
|
1236
|
-
}
|
|
1237
|
-
const scriptStale = scriptMtime < onDiskScript - 1;
|
|
1238
|
-
const serverStale = serverBuildMtime < onDiskServer - 1;
|
|
1239
|
-
const pair = `${agentId} (pusher ${stamp(scriptMtime)} · server ${stamp(serverBuildMtime)})`;
|
|
1240
|
-
if (scriptStale && serverStale)
|
|
1241
|
-
bothStale.push(pair);
|
|
1242
|
-
else if (scriptStale !== serverStale) {
|
|
1243
|
-
split.push(`${pair} — ${scriptStale ? "pusher stale, server current" : "pusher CURRENT, server STALE"}`);
|
|
1244
|
-
}
|
|
1245
|
-
else
|
|
1246
|
-
fresh++;
|
|
1247
|
-
}
|
|
1248
|
-
const bad = [...split, ...bothStale, ...unknown];
|
|
1249
|
-
findings.push({
|
|
1250
|
-
check: "transport-build-split",
|
|
1251
|
-
level: split.length || bothStale.length ? "warn" : unknown.length ? "warn" : "ok",
|
|
1252
|
-
detail: bad.length
|
|
1253
|
-
? `${split.length} pane(s) SPLIT (the two components are at different builds under one version number — a re-attach reloads the pusher and NOT the server, so this needs a SESSION restart), ` +
|
|
1254
|
-
`${bothStale.length} fully stale, ${unknown.length} unclassifiable, ${fresh} fully current. On-disk now: pusher ${stamp(onDiskScript)} · server ${stamp(onDiskServer)}. Times are ISO-8601 UTC.`
|
|
1255
|
-
: `${fresh} local pane(s) fully current — pusher and server both at the on-disk build (ISO-8601 UTC)`,
|
|
1256
|
-
fixable: false,
|
|
1257
|
-
items: bad.length ? bad : undefined,
|
|
1258
|
-
});
|
|
1259
|
-
}
|
|
1260
|
-
// 1c. Wedged local pushers (pid-alive, pane-dead). v0.8.0 made pushers
|
|
1261
|
-
// self-exit when their own tmux-target probe finds the pane gone, but
|
|
1262
|
-
// that only fires from inside the pusher's own poll loop — if the pane
|
|
1263
|
-
// is killed in a way that loop never observes (or the loop itself is
|
|
1264
|
-
// wedged), the pid stays alive, isMarkerLive's pid-alive check keeps
|
|
1265
|
-
// treating it as live, and list_agents reports it "live" while nothing
|
|
1266
|
-
// can actually be delivered. Local tmux-push only — a tmux-push-remote
|
|
1267
|
-
// marker's pane lives on a different host, unprobeable from here.
|
|
1268
|
-
{
|
|
1269
|
-
// Without a tmux binary we can't tell "wedged" from "can't probe" — skip
|
|
1270
|
-
// rather than flag every local marker as dead.
|
|
1271
|
-
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
|
|
1272
|
-
const wedged = [];
|
|
1273
|
-
if (tmuxAvailable) {
|
|
1274
|
-
for (const fname of await listTransportFiles()) {
|
|
1275
|
-
const file = path.join(TRANSPORT_DIR, fname);
|
|
1276
|
-
const marker = await readJson(file, null);
|
|
1277
|
-
if (!marker || !isMarkerLive(marker, reg, now))
|
|
1278
|
-
continue;
|
|
1279
|
-
if (marker.transport !== "tmux-push")
|
|
1280
|
-
continue; // remote = no local pane to probe
|
|
1281
|
-
if (!marker.tmuxTarget)
|
|
1282
|
-
continue; // no target recorded, can't probe
|
|
1283
|
-
// has-session actually validates the target and fails on a dead
|
|
1284
|
-
// pane/session; `display-message -p -t <target> <literal>` does NOT
|
|
1285
|
-
// (tmux 3.6b exits 0 for any target, even a just-killed one, when
|
|
1286
|
-
// the format string has no #{...} needing that target resolved).
|
|
1287
|
-
const probe = spawnSync("tmux", ["has-session", "-t", marker.tmuxTarget]);
|
|
1288
|
-
if (probe.status === 0)
|
|
1289
|
-
continue; // pane alive
|
|
1290
|
-
// The marker's pid being alive does not make it OUR pid — see
|
|
1291
|
-
// isPusherProcess. Record the verdict now so `fix` only ever signals
|
|
1292
|
-
// a confirmed pusher.
|
|
1293
|
-
wedged.push({
|
|
1294
|
-
agentId: marker.agentId,
|
|
1295
|
-
pid: marker.pid,
|
|
1296
|
-
file,
|
|
1297
|
-
target: marker.tmuxTarget,
|
|
1298
|
-
isPusher: isPusherProcess(marker.pid),
|
|
1299
|
-
});
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
if (fix) {
|
|
1303
|
-
for (const w of wedged) {
|
|
1304
|
-
// Clearing the marker is always safe — the pane is gone either way, so
|
|
1305
|
-
// nothing can be delivered through it. Signalling is not: an
|
|
1306
|
-
// unverifiable pid is some other process that inherited this number.
|
|
1307
|
-
if (w.isPusher) {
|
|
1308
|
-
try {
|
|
1309
|
-
process.kill(w.pid, "SIGTERM");
|
|
1310
|
-
}
|
|
1311
|
-
catch { /* already gone */ }
|
|
1312
|
-
}
|
|
1313
|
-
await deleteFile(w.file);
|
|
1314
|
-
fixed.push(w.isPusher
|
|
1315
|
-
? `reaped wedged pusher for ${w.agentId} (pid ${w.pid}, tmux target '${w.target}' gone)`
|
|
1316
|
-
: `cleared stale transport marker for ${w.agentId} (tmux target '${w.target}' gone; pid ${w.pid} is not a tmux-pusher — not signalled)`);
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
findings.push({
|
|
1320
|
-
check: "wedged-local-pushers",
|
|
1321
|
-
level: wedged.length ? "warn" : "ok",
|
|
1322
|
-
detail: wedged.length
|
|
1323
|
-
? `${wedged.length} local pusher(s) alive (pid) but their tmux pane is gone — looks attached, delivers nothing. ${fix ? "Reaped (SIGTERM + marker cleared)." : "Run doctor with fix:true to SIGTERM and clear the marker."}`
|
|
1324
|
-
: tmuxAvailable
|
|
1325
|
-
? "no wedged local pushers (pid-alive, pane-dead)"
|
|
1326
|
-
: "tmux not available — skipped wedged-pusher pane probe",
|
|
1327
|
-
fixable: true,
|
|
1328
|
-
items: wedged.length
|
|
1329
|
-
? wedged.map((w) => `${w.agentId} (pid ${w.pid}, tmux target '${w.target}')${w.isPusher ? "" : " — pid is not a tmux-pusher, marker will be cleared without signalling"}`)
|
|
1330
|
-
: undefined,
|
|
1331
|
-
});
|
|
1332
|
-
}
|
|
1333
|
-
// 1d. Duplicate session bindings — two live MCP sessions bound to one agent
|
|
1334
|
-
// id means two processes are ACTING as the same agent (the
|
|
1335
|
-
// `<project>-liaison` shape: a dev session bound onto a live worker's id;
|
|
1336
|
-
// force/token make that possible on purpose, this makes it visible).
|
|
1337
|
-
// Bindings are per-process closure state, so this reads the on-disk
|
|
1338
|
-
// session markers stdio servers write at bind time. A marker whose pid
|
|
1339
|
-
// is dead is litter from a killed session (default signal death skips
|
|
1340
|
-
// exit handlers) — cleaned under fix. Live duplicates are NOT auto-
|
|
1341
|
-
// fixable: doctor cannot know which of two running sessions is the
|
|
1342
|
-
// impostor; the wrong session should `quit` (its marker clears on exit).
|
|
1343
|
-
{
|
|
1344
|
-
const byAgent = new Map();
|
|
1345
|
-
const stale = [];
|
|
1346
|
-
for (const file of await listSessionFiles()) {
|
|
1347
|
-
const s = await readJson(file, null);
|
|
1348
|
-
if (!s || typeof s.pid !== "number" || !s.agentId || !isPidAlive(s.pid)) {
|
|
1349
|
-
stale.push(path.basename(file));
|
|
1350
|
-
if (fix) {
|
|
1351
|
-
await deleteFile(file);
|
|
1352
|
-
fixed.push(`deleted stale session binding ${path.basename(file)}`);
|
|
1353
|
-
}
|
|
1354
|
-
continue;
|
|
1355
|
-
}
|
|
1356
|
-
const list = byAgent.get(s.agentId) ?? [];
|
|
1357
|
-
list.push({ pid: s.pid, via: s.via ?? "unknown", boundAt: s.boundAt ?? 0 });
|
|
1358
|
-
byAgent.set(s.agentId, list);
|
|
1359
|
-
}
|
|
1360
|
-
const dupes = [];
|
|
1361
|
-
for (const [id, list] of byAgent) {
|
|
1362
|
-
if (list.length < 2)
|
|
1363
|
-
continue;
|
|
1364
|
-
dupes.push(`${id} — ${list
|
|
1365
|
-
.map((b) => `pid ${b.pid} (via ${b.via}, bound ${b.boundAt ? new Date(b.boundAt).toISOString() : "unknown"})`)
|
|
1366
|
-
.join(" AND ")}`);
|
|
1367
|
-
}
|
|
1368
|
-
findings.push({
|
|
1369
|
-
check: "duplicate-session-binding",
|
|
1370
|
-
level: dupes.length ? "warn" : "ok",
|
|
1371
|
-
detail: dupes.length
|
|
1372
|
-
? `${dupes.length} agent id(s) bound by more than one live session — two processes are acting as the same agent. Decide which is legitimate; the other should quit (its binding clears on exit).`
|
|
1373
|
-
: stale.length
|
|
1374
|
-
? `no duplicate session bindings (${stale.length} stale binding file(s) from dead sessions${fix ? " — cleaned" : "; run doctor with fix:true to clean"})`
|
|
1375
|
-
: "no duplicate session bindings",
|
|
1376
|
-
fixable: true,
|
|
1377
|
-
items: dupes.length ? dupes : undefined,
|
|
1378
|
-
});
|
|
1379
|
-
}
|
|
1380
|
-
// 2. Orphan room memberships (member not in the registry).
|
|
1381
|
-
{
|
|
1382
|
-
const orphans = new Set();
|
|
1383
|
-
for (const e of Object.values(rooms)) {
|
|
1384
|
-
for (const m of e.members ?? [])
|
|
1385
|
-
if (!known.has(m))
|
|
1386
|
-
orphans.add(m);
|
|
1387
|
-
}
|
|
1388
|
-
if (fix && orphans.size) {
|
|
1389
|
-
await updateJson(ROOMS_FILE, {}, (cur) => {
|
|
1390
|
-
for (const e of Object.values(cur)) {
|
|
1391
|
-
if (e.members?.length)
|
|
1392
|
-
e.members = e.members.filter((m) => known.has(m));
|
|
1393
|
-
}
|
|
1394
|
-
return cur;
|
|
1395
|
-
});
|
|
1396
|
-
fixed.push(`dropped ${orphans.size} orphan membership(s): ${[...orphans].join(", ")}`);
|
|
1397
|
-
}
|
|
1398
|
-
findings.push({
|
|
1399
|
-
check: "orphan-room-memberships",
|
|
1400
|
-
level: orphans.size ? "warn" : "ok",
|
|
1401
|
-
detail: orphans.size ? `${orphans.size} channel member(s) not in the registry` : "all channel members are registered",
|
|
1402
|
-
fixable: true,
|
|
1403
|
-
items: orphans.size ? [...orphans] : undefined,
|
|
1404
|
-
});
|
|
1405
|
-
}
|
|
1406
|
-
// 3. Orphan inbox / cursor files (owner not registered).
|
|
1407
|
-
{
|
|
1408
|
-
const orphanInbox = [];
|
|
1409
|
-
for (const fname of await listInboxFiles()) {
|
|
1410
|
-
const id = fname.replace(/\.jsonl$/, "");
|
|
1411
|
-
if (!known.has(id)) {
|
|
1412
|
-
orphanInbox.push(id);
|
|
1413
|
-
if (fix) {
|
|
1414
|
-
await deleteFile(path.join(INBOX_DIR, fname));
|
|
1415
|
-
fixed.push(`deleted orphan inbox ${fname}`);
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
const orphanCursor = [];
|
|
1420
|
-
for (const fname of await listCursorFiles()) {
|
|
1421
|
-
const id = fname.replace(/\.json$/, "");
|
|
1422
|
-
if (!known.has(id)) {
|
|
1423
|
-
orphanCursor.push(id);
|
|
1424
|
-
if (fix) {
|
|
1425
|
-
await deleteFile(path.join(CURSOR_DIR, fname));
|
|
1426
|
-
fixed.push(`deleted orphan cursor ${fname}`);
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
const total = orphanInbox.length + orphanCursor.length;
|
|
1431
|
-
findings.push({
|
|
1432
|
-
check: "orphan-inboxes-cursors",
|
|
1433
|
-
level: total ? "warn" : "ok",
|
|
1434
|
-
detail: total
|
|
1435
|
-
? `${orphanInbox.length} inbox + ${orphanCursor.length} cursor file(s) for unregistered ids`
|
|
1436
|
-
: "no orphan inbox/cursor files",
|
|
1437
|
-
fixable: true,
|
|
1438
|
-
items: total ? [...new Set([...orphanInbox, ...orphanCursor])] : undefined,
|
|
1439
|
-
});
|
|
1440
|
-
}
|
|
1441
|
-
// Precompute parsed line counts for cursor + malformed checks.
|
|
1442
|
-
const counts = new Map();
|
|
1443
|
-
const countFor = async (file) => {
|
|
1444
|
-
if (!counts.has(file))
|
|
1445
|
-
counts.set(file, await scanJsonl(file));
|
|
1446
|
-
return counts.get(file);
|
|
1447
|
-
};
|
|
1448
|
-
// 4. Cursor offsets past end-of-file (would return [] forever).
|
|
1449
|
-
{
|
|
1450
|
-
const broken = [];
|
|
1451
|
-
for (const fname of await listCursorFiles()) {
|
|
1452
|
-
const id = fname.replace(/\.json$/, "");
|
|
1453
|
-
const cursorPath = path.join(CURSOR_DIR, fname);
|
|
1454
|
-
const cursor = await readJson(cursorPath, {});
|
|
1455
|
-
const overflow = [];
|
|
1456
|
-
const inboxMax = (await countFor(inboxFile(id))).parsed;
|
|
1457
|
-
if ((cursor.inboxOffset ?? 0) > inboxMax)
|
|
1458
|
-
overflow.push(`inboxOffset ${cursor.inboxOffset}>${inboxMax}`);
|
|
1459
|
-
const roomMax = (await countFor(ROOM_FILE)).parsed;
|
|
1460
|
-
if ((cursor.roomOffset ?? 0) > roomMax)
|
|
1461
|
-
overflow.push(`roomOffset ${cursor.roomOffset}>${roomMax}`);
|
|
1462
|
-
const statusMax = (await countFor(STATUS_FILE)).parsed;
|
|
1463
|
-
if ((cursor.statusOffset ?? 0) > statusMax)
|
|
1464
|
-
overflow.push(`statusOffset ${cursor.statusOffset}>${statusMax}`);
|
|
1465
|
-
for (const [chan, off] of Object.entries(cursor.roomOffsets ?? {})) {
|
|
1466
|
-
const max = (await countFor(roomFile(chan))).parsed;
|
|
1467
|
-
if (off > max)
|
|
1468
|
-
overflow.push(`roomOffsets[${chan}] ${off}>${max}`);
|
|
1469
|
-
}
|
|
1470
|
-
if (overflow.length) {
|
|
1471
|
-
broken.push(`${id}: ${overflow.join(", ")}`);
|
|
1472
|
-
if (fix) {
|
|
1473
|
-
await updateJson(cursorPath, {}, (c) => {
|
|
1474
|
-
if ((c.inboxOffset ?? 0) > inboxMax)
|
|
1475
|
-
c.inboxOffset = inboxMax;
|
|
1476
|
-
if ((c.roomOffset ?? 0) > roomMax)
|
|
1477
|
-
c.roomOffset = roomMax;
|
|
1478
|
-
if ((c.statusOffset ?? 0) > statusMax)
|
|
1479
|
-
c.statusOffset = statusMax;
|
|
1480
|
-
if (c.roomOffsets) {
|
|
1481
|
-
for (const chan of Object.keys(c.roomOffsets)) {
|
|
1482
|
-
const max = counts.get(roomFile(chan))?.parsed ?? 0;
|
|
1483
|
-
if (c.roomOffsets[chan] > max)
|
|
1484
|
-
c.roomOffsets[chan] = max;
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
return c;
|
|
1488
|
-
});
|
|
1489
|
-
fixed.push(`clamped cursor offsets for ${id}`);
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
findings.push({
|
|
1494
|
-
check: "cursor-past-eof",
|
|
1495
|
-
level: broken.length ? "error" : "ok",
|
|
1496
|
-
detail: broken.length ? `${broken.length} cursor(s) with an offset past EOF — delivery stalled` : "all cursor offsets are within bounds",
|
|
1497
|
-
fixable: true,
|
|
1498
|
-
items: broken.length ? broken : undefined,
|
|
1499
|
-
});
|
|
1500
|
-
}
|
|
1501
|
-
// 5. Malformed JSONL lines (silently desync offset math between server + hooks).
|
|
1502
|
-
{
|
|
1503
|
-
const jsonlFiles = [
|
|
1504
|
-
ROOM_FILE,
|
|
1505
|
-
STATUS_FILE,
|
|
1506
|
-
...channels.filter((c) => c !== DEFAULT_ROOM).map((c) => roomFile(c)),
|
|
1507
|
-
...(await listInboxFiles()).map((f) => path.join(INBOX_DIR, f)),
|
|
1508
|
-
];
|
|
1509
|
-
const bad = [];
|
|
1510
|
-
for (const file of jsonlFiles) {
|
|
1511
|
-
const c = await countFor(file);
|
|
1512
|
-
if (c.malformed > 0) {
|
|
1513
|
-
bad.push(`${path.basename(file)} (${c.malformed})`);
|
|
1514
|
-
if (fix) {
|
|
1515
|
-
await fsp.copyFile(file, file + ".bak");
|
|
1516
|
-
await rewriteJsonl(file, () => true); // drops unparseable lines
|
|
1517
|
-
fixed.push(`rewrote ${path.basename(file)} dropping ${c.malformed} malformed line(s) (backup: ${path.basename(file)}.bak)`);
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
findings.push({
|
|
1522
|
-
check: "malformed-jsonl",
|
|
1523
|
-
level: bad.length ? "warn" : "ok",
|
|
1524
|
-
detail: bad.length ? `${bad.length} file(s) contain unparseable lines` : "no malformed JSONL lines",
|
|
1525
|
-
fixable: true,
|
|
1526
|
-
items: bad.length ? bad : undefined,
|
|
1527
|
-
});
|
|
1528
|
-
}
|
|
1529
|
-
// 6. Stale agents (registered, no live transport, heartbeat past EVICT_MS). Report only.
|
|
1530
|
-
{
|
|
1531
|
-
// Compute liveness WITHOUT deleting dead markers — loadLiveTransports
|
|
1532
|
-
// prunes as a side effect, which would make this read-only check mutate
|
|
1533
|
-
// state (and pre-empt the orphan-marker fix in check 1).
|
|
1534
|
-
const live = new Set();
|
|
1535
|
-
for (const fname of await listTransportFiles()) {
|
|
1536
|
-
const marker = await readJson(path.join(TRANSPORT_DIR, fname), null);
|
|
1537
|
-
if (marker && isMarkerLive(marker, reg, now))
|
|
1538
|
-
live.add(marker.agentId);
|
|
1539
|
-
}
|
|
1540
|
-
const stale = [];
|
|
1541
|
-
for (const [id, a] of Object.entries(reg)) {
|
|
1542
|
-
if (live.has(id))
|
|
1543
|
-
continue;
|
|
1544
|
-
if (now - a.lastHeartbeat > EVICT_MS)
|
|
1545
|
-
stale.push(`${id} (${Math.floor((now - a.lastHeartbeat) / 3600000)}h)`);
|
|
1546
|
-
}
|
|
1547
|
-
findings.push({
|
|
1548
|
-
check: "stale-agents",
|
|
1549
|
-
level: stale.length ? "warn" : "ok",
|
|
1550
|
-
detail: stale.length ? `${stale.length} agent(s) past the eviction window — next list_agents will drop them` : "no stale agents",
|
|
1551
|
-
fixable: false,
|
|
1552
|
-
items: stale.length ? stale : undefined,
|
|
1553
|
-
});
|
|
1554
|
-
}
|
|
1555
|
-
// 6b. STALENESS HAS TWO CAUSES AND THEY LOOK IDENTICAL IN A FLAT LIST.
|
|
1556
|
-
//
|
|
1557
|
-
// Check 6 reports "these N agents are stale", which is the detection half and
|
|
1558
|
-
// leaves the reader to infer the cause. The inference is the expensive part: a
|
|
1559
|
-
// coordinator misdiagnosed a billing outage as a coordination failure for want
|
|
1560
|
-
// of exactly this distinction, and spent the incident chasing agents.
|
|
1561
|
-
//
|
|
1562
|
-
// UNIFORM every agent stale by roughly the same interval. Agents do not
|
|
1563
|
-
// fail in lockstep — something they SHARE did: the machine slept,
|
|
1564
|
-
// the API key expired, billing lapsed, the host lost network. The
|
|
1565
|
-
// remedy is the environment, and touching the agents does nothing.
|
|
1566
|
-
// DIVERGENT some stale, some fresh, on the same bus at the same moment. The
|
|
1567
|
-
// shared substrate is demonstrably working, so the fault is the
|
|
1568
|
-
// stale agent's. The remedy is that agent.
|
|
1569
|
-
//
|
|
1570
|
-
// CLASSIFICATION IS THE DELIVERABLE, NOT DETECTION — the same finding as
|
|
1571
|
-
// `transport-build-split`: two states with different remedies reported as one
|
|
1572
|
-
// warning leave the reader to guess, and the guess is made under incident
|
|
1573
|
-
// pressure.
|
|
1574
|
-
//
|
|
1575
|
-
// WITH FEWER THAN TWO AGENTS THE QUESTION IS UNANSWERABLE and it says so. One
|
|
1576
|
-
// stale agent on a bus of one is uniform and divergent simultaneously; there is
|
|
1577
|
-
// no second observation to compare against. Naming a cause there would be the
|
|
1578
|
-
// confident-wrong-answer shape this whole class is about.
|
|
1579
|
-
{
|
|
1580
|
-
const live = new Set();
|
|
1581
|
-
for (const fname of await listTransportFiles()) {
|
|
1582
|
-
const marker = await readJson(path.join(TRANSPORT_DIR, fname), null);
|
|
1583
|
-
if (marker && isMarkerLive(marker, reg, now))
|
|
1584
|
-
live.add(marker.agentId);
|
|
1585
|
-
}
|
|
1586
|
-
const entries = Object.entries(reg).map(([id, a]) => ({
|
|
1587
|
-
id,
|
|
1588
|
-
live: live.has(id),
|
|
1589
|
-
ageMs: now - a.lastHeartbeat,
|
|
1590
|
-
stale: !live.has(id) && now - a.lastHeartbeat > STALE_MS,
|
|
1591
|
-
}));
|
|
1592
|
-
const stale = entries.filter((e) => e.stale);
|
|
1593
|
-
const fresh = entries.filter((e) => !e.stale);
|
|
1594
|
-
const mins = (ms) => Math.round(ms / 60000);
|
|
1595
|
-
let level = "ok";
|
|
1596
|
-
let detail;
|
|
1597
|
-
let items;
|
|
1598
|
-
if (entries.length === 0) {
|
|
1599
|
-
detail = "no registered agents — nothing to classify";
|
|
1600
|
-
}
|
|
1601
|
-
else if (stale.length === 0) {
|
|
1602
|
-
detail = `${entries.length} agent(s), none stale (threshold ${mins(STALE_MS)}m)`;
|
|
1603
|
-
}
|
|
1604
|
-
else if (entries.length < 2) {
|
|
1605
|
-
level = "warn";
|
|
1606
|
-
detail =
|
|
1607
|
-
`1 agent and it is stale (${mins(stale[0].ageMs)}m) — UNCLASSIFIABLE. Uniform and divergent are the same ` +
|
|
1608
|
-
`picture with one observation, so the cause is not named here rather than guessed.`;
|
|
1609
|
-
items = [`${stale[0].id} (${mins(stale[0].ageMs)}m)`];
|
|
1610
|
-
}
|
|
1611
|
-
else if (fresh.length === 0) {
|
|
1612
|
-
// Spread across the stale set decides it: a shared cause stops everything at
|
|
1613
|
-
// once, so the ages cluster. Independent failures do not.
|
|
1614
|
-
const ages = stale.map((e) => e.ageMs).sort((a, b) => a - b);
|
|
1615
|
-
const spread = ages[ages.length - 1] - ages[0];
|
|
1616
|
-
const tight = spread <= Math.max(2 * 60 * 1000, ages[ages.length - 1] * 0.1);
|
|
1617
|
-
level = "warn";
|
|
1618
|
-
detail = tight
|
|
1619
|
-
? `UNIFORM: all ${stale.length} agent(s) stale within ${mins(spread)}m of each other (${mins(ages[0])}–${mins(ages[ages.length - 1])}m). ` +
|
|
1620
|
-
`Agents do not fail in lockstep — suspect something they SHARE (machine asleep, credentials, billing, network). ` +
|
|
1621
|
-
`Restarting agents will not help.`
|
|
1622
|
-
: `ALL ${stale.length} agent(s) are stale but their ages span ${mins(spread)}m — NOT uniform, so a single shared cause does not explain it. ` +
|
|
1623
|
-
`Treat as ${stale.length} independent failures until something ties them together.`;
|
|
1624
|
-
items = stale.map((e) => `${e.id} (${mins(e.ageMs)}m)`);
|
|
1625
|
-
}
|
|
1626
|
-
else {
|
|
1627
|
-
level = "warn";
|
|
1628
|
-
detail =
|
|
1629
|
-
`DIVERGENT: ${stale.length} stale, ${fresh.length} fresh on the same bus at the same moment. ` +
|
|
1630
|
-
`The shared substrate is demonstrably working — the fault is the stale agent's, not the environment's.`;
|
|
1631
|
-
items = stale.map((e) => `${e.id} (${mins(e.ageMs)}m stale)`);
|
|
1632
|
-
}
|
|
1633
|
-
findings.push({ check: "staleness-class", level, detail, fixable: false, ...(items ? { items } : {}) });
|
|
1634
|
-
}
|
|
1635
|
-
// 7. Oversized JSONL files. Report only (suggest prune).
|
|
1636
|
-
{
|
|
1637
|
-
const big = [];
|
|
1638
|
-
const candidates = [
|
|
1639
|
-
ROOM_FILE,
|
|
1640
|
-
STATUS_FILE,
|
|
1641
|
-
...channels.filter((c) => c !== DEFAULT_ROOM).map((c) => roomFile(c)),
|
|
1642
|
-
...(await listInboxFiles()).map((f) => path.join(INBOX_DIR, f)),
|
|
1643
|
-
];
|
|
1644
|
-
for (const file of candidates) {
|
|
1645
|
-
const sz = await fileSize(file);
|
|
1646
|
-
if (sz > maxBytes)
|
|
1647
|
-
big.push(`${path.basename(file)} (${(sz / 1024 / 1024).toFixed(1)}MB)`);
|
|
1648
|
-
}
|
|
1649
|
-
findings.push({
|
|
1650
|
-
check: "oversized-files",
|
|
1651
|
-
level: big.length ? "warn" : "ok",
|
|
1652
|
-
detail: big.length ? `${big.length} file(s) over ${(maxBytes / 1024 / 1024).toFixed(0)}MB — consider prune` : "no oversized files",
|
|
1653
|
-
fixable: false,
|
|
1654
|
-
items: big.length ? big : undefined,
|
|
1655
|
-
});
|
|
1656
|
-
}
|
|
1657
|
-
// 8. Stale lock dirs from crashed writers.
|
|
1658
|
-
{
|
|
1659
|
-
const locks = await scanStaleLocks(60_000, now);
|
|
1660
|
-
for (const l of locks) {
|
|
1661
|
-
if (fix) {
|
|
1662
|
-
try {
|
|
1663
|
-
await fsp.rm(l.path, { recursive: true, force: true });
|
|
1664
|
-
fixed.push(`removed stale lock ${path.basename(l.path)}`);
|
|
1665
|
-
}
|
|
1666
|
-
catch {
|
|
1667
|
-
// ignore
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
findings.push({
|
|
1672
|
-
check: "stale-locks",
|
|
1673
|
-
level: locks.length ? "warn" : "ok",
|
|
1674
|
-
detail: locks.length ? `${locks.length} lock dir(s) older than 60s — likely from a crashed writer` : "no stale locks",
|
|
1675
|
-
fixable: true,
|
|
1676
|
-
items: locks.length ? locks.map((l) => `${path.basename(l.path)} (${Math.floor(l.ageMs / 1000)}s)`) : undefined,
|
|
1677
|
-
});
|
|
1678
|
-
}
|
|
1679
|
-
// 9. Channel/registry consistency: rooms/<chan>.jsonl files without a registry entry.
|
|
1680
|
-
{
|
|
1681
|
-
const orphanFiles = [];
|
|
1682
|
-
if (existsSync(ROOMS_DIR)) {
|
|
1683
|
-
let names = [];
|
|
1684
|
-
try {
|
|
1685
|
-
names = await fsp.readdir(ROOMS_DIR);
|
|
1686
|
-
}
|
|
1687
|
-
catch {
|
|
1688
|
-
// ignore
|
|
1689
|
-
}
|
|
1690
|
-
for (const name of names) {
|
|
1691
|
-
if (!name.endsWith(".jsonl"))
|
|
1692
|
-
continue;
|
|
1693
|
-
const chan = name.replace(/\.jsonl$/, "");
|
|
1694
|
-
if (!rooms[chan]) {
|
|
1695
|
-
orphanFiles.push(name);
|
|
1696
|
-
if (fix) {
|
|
1697
|
-
await ensureRoom(chan, "doctor");
|
|
1698
|
-
fixed.push(`registered channel '${chan}' (had a JSONL file but no registry entry)`);
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
findings.push({
|
|
1704
|
-
check: "channel-registry-consistency",
|
|
1705
|
-
level: orphanFiles.length ? "warn" : "ok",
|
|
1706
|
-
detail: orphanFiles.length ? `${orphanFiles.length} channel file(s) with no registry entry` : "channel files and registry agree",
|
|
1707
|
-
fixable: true,
|
|
1708
|
-
items: orphanFiles.length ? orphanFiles : undefined,
|
|
1709
|
-
});
|
|
1710
|
-
}
|
|
1711
|
-
// 9b. Document scope drift (Phase 8 Task 4). For each document declared in
|
|
1712
|
-
// scopes.json, compare git's last writer against the declared owner.
|
|
1713
|
-
//
|
|
1714
|
-
// DETECTION ONLY, never fixable — rewriting or reverting someone else's
|
|
1715
|
-
// file is not a safe automatic repair, and the bus cannot prevent the
|
|
1716
|
-
// write in the first place (agents edit these with ordinary file tools;
|
|
1717
|
-
// enforcement waits for Task 5). Skips silently when no scopes.json
|
|
1718
|
-
// exists (opt-in) or when the declared repo isn't a git checkout, the
|
|
1719
|
-
// same way the wedged-pusher check skips without tmux — a check that
|
|
1720
|
-
// can't observe anything must not guess.
|
|
1721
|
-
{
|
|
1722
|
-
const scopes = await loadScopes();
|
|
1723
|
-
const drift = [];
|
|
1724
|
-
const unattributed = [];
|
|
1725
|
-
let detail;
|
|
1726
|
-
if (!scopes.documents.length) {
|
|
1727
|
-
detail = scopes.configured
|
|
1728
|
-
? `${path.basename(scopes.file)} declares no documents`
|
|
1729
|
-
: `no ${path.basename(scopes.file)} — document scopes are opt-in and none are declared`;
|
|
1730
|
-
}
|
|
1731
|
-
else if (!isGitRepo(scopes.repo)) {
|
|
1732
|
-
detail = `${scopes.documents.length} document(s) declared but '${scopes.repo}' is not a git checkout — last writer is unknowable, check skipped`;
|
|
1733
|
-
}
|
|
1734
|
-
else {
|
|
1735
|
-
for (const doc of scopes.documents) {
|
|
1736
|
-
const writer = lastWriterOf(scopes.repo, doc.path);
|
|
1737
|
-
if (!writer)
|
|
1738
|
-
continue; // never committed — nothing has written it yet
|
|
1739
|
-
const who = attributeWriter(writer, reg);
|
|
1740
|
-
if (!who) {
|
|
1741
|
-
// Commits are authored by humans/machine accounts, not agent ids, so
|
|
1742
|
-
// an unmappable author is the normal case — reported, never flagged.
|
|
1743
|
-
unattributed.push(`${doc.path}: last written by '${writer.author}' (${writer.commit}), not attributable to a registered agent`);
|
|
1744
|
-
continue;
|
|
1745
|
-
}
|
|
1746
|
-
if (ownsDocument(who.agentId, reg[who.agentId], doc.owner))
|
|
1747
|
-
continue;
|
|
1748
|
-
drift.push(`${doc.path}: declared owner '${doc.owner}' (${doc.mode}) but last written by '${who.agentId}'` +
|
|
1749
|
-
`${who.roleId ? ` [role ${who.roleId}]` : ""} in ${writer.commit} (${writer.when})`);
|
|
1750
|
-
}
|
|
1751
|
-
detail = drift.length
|
|
1752
|
-
? `${drift.length} document(s) last written by someone other than their declared owner — advisory: coordinate ownership, doctor will not rewrite anyone's file`
|
|
1753
|
-
: `${scopes.documents.length} declared document(s) agree with their scope`;
|
|
1754
|
-
}
|
|
1755
|
-
findings.push({
|
|
1756
|
-
check: "document-scope-drift",
|
|
1757
|
-
level: drift.length ? "warn" : "ok",
|
|
1758
|
-
detail,
|
|
1759
|
-
fixable: false,
|
|
1760
|
-
items: drift.length ? drift : unattributed.length ? unattributed : undefined,
|
|
1761
|
-
});
|
|
1762
|
-
}
|
|
1763
|
-
// 10. Environment sanity. Report only. Path + version come from the running
|
|
1764
|
-
// module's package.json so a stale/decoy bin is named (LESSONS #35). Git
|
|
1765
|
-
// identity is walk-up-to-.git + rev-parse (kit monorepo root); omitted
|
|
1766
|
-
// entirely when there is no checkout — never invented.
|
|
1767
|
-
{
|
|
1768
|
-
const tmuxProbe = spawnSync("tmux", ["-V"]);
|
|
1769
|
-
const tmuxOk = tmuxProbe.status === 0;
|
|
1770
|
-
const ident = resolveServerIdentity();
|
|
1771
|
-
const loc = ident.branch && ident.sha
|
|
1772
|
-
? `path=${ident.path} version=${ident.version} ${ident.branch}@${ident.sha.slice(0, 12)}`
|
|
1773
|
-
: `path=${ident.path} version=${ident.version}`;
|
|
1774
|
-
const items = [
|
|
1775
|
-
`root=${ROOT}`,
|
|
1776
|
-
`execPath=${process.execPath}`,
|
|
1777
|
-
`inTmux=${!!process.env.TMUX_PANE}`,
|
|
1778
|
-
`path=${ident.path}`,
|
|
1779
|
-
`version=${ident.version}`,
|
|
1780
|
-
];
|
|
1781
|
-
if (ident.branch)
|
|
1782
|
-
items.push(`branch=${ident.branch}`);
|
|
1783
|
-
if (ident.sha)
|
|
1784
|
-
items.push(`sha=${ident.sha}`);
|
|
1785
|
-
findings.push({
|
|
1786
|
-
check: "environment",
|
|
1787
|
-
level: tmuxOk ? "ok" : "warn",
|
|
1788
|
-
detail: tmuxOk
|
|
1789
|
-
? `root=${ROOT}; node=${process.execPath}; ${loc}; tmux=${(tmuxProbe.stdout ?? "").toString().trim() || "present"}`
|
|
1790
|
-
: `root=${ROOT}; node=${process.execPath}; ${loc}; tmux NOT on PATH — the tmux-push transport will not work`,
|
|
1791
|
-
fixable: false,
|
|
1792
|
-
items,
|
|
1793
|
-
});
|
|
1794
|
-
}
|
|
1795
|
-
const summary = {
|
|
1796
|
-
ok: findings.filter((f) => f.level === "ok").length,
|
|
1797
|
-
warn: findings.filter((f) => f.level === "warn").length,
|
|
1798
|
-
error: findings.filter((f) => f.level === "error").length,
|
|
1799
|
-
};
|
|
1800
|
-
return {
|
|
1801
|
-
ok: true,
|
|
1802
|
-
healthy: summary.warn === 0 && summary.error === 0,
|
|
1803
|
-
fixApplied: fix,
|
|
1804
|
-
root: ROOT,
|
|
1805
|
-
findings,
|
|
1806
|
-
fixed: fix ? fixed : undefined,
|
|
1807
|
-
summary,
|
|
1808
|
-
};
|
|
1809
|
-
}
|
|
1810
|
-
//# sourceMappingURL=transport.js.map
|