agent-coord-mcp 0.19.0 → 0.23.0
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/README.md +12 -2
- package/dist/build.js.map +1 -1
- package/dist/roles.js +10 -0
- package/dist/roles.js.map +1 -1
- package/dist/server.js +164 -42
- package/dist/server.js.map +1 -1
- package/dist/store.js +33 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +24 -5
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/registry.js +69 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/render.js +1 -83
- package/dist/tools/render.js.map +1 -1
- package/dist/tools/shared.js +1 -3
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +97 -7
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +98 -24
- package/dist/tools/work.js.map +1 -1
- package/dist/work.js +1 -259
- package/dist/work.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/roles.mjs +12 -0
- package/hooks/tier.mjs +9 -4
- package/hooks/tmux-pusher.mjs +16 -5
- package/package.json +16 -16
- package/scripts/check-self-dependency.mjs +62 -14
- package/scripts/check-test-count.mjs +3 -3
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-pusher.mjs +39 -9
- package/scripts/coord-token.mjs +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/src/roles.ts +12 -0
- package/src/server.ts +181 -47
- package/src/store.ts +45 -1
- package/src/tools/admin.ts +22 -5
- package/src/tools/registry.ts +96 -0
- package/src/tools/render.ts +1 -80
- package/src/tools/shared.ts +18 -77
- package/src/tools/transport.ts +116 -6
- package/src/tools/work.ts +118 -30
- package/src/work.ts +31 -329
- package/dist/tools.js +0 -1852
- package/dist/tools.js.map +0 -1
package/dist/tools.js
DELETED
|
@@ -1,1852 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, openSync, watch } from "node:fs";
|
|
3
|
-
import { promises as fsp } from "node:fs";
|
|
4
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { z } from "zod";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import { AGENTS_FILE, CURSOR_DIR, DEFAULT_ROOM, INBOX_DIR, ROOT, ROOM_FILE, ROOMS_DIR, ROOMS_FILE, STATUS_FILE, addMember, appendJsonl, cursorFile, deleteFile, ensureRoom, fileSize, getRooms, inboxFile, listCursorFiles, listInboxFiles, listTransportFiles, logFile, memberRooms, normalizeRoom, pidFile, readJson, readJsonl, receiptFile, listReceiptFiles, removeMember, rewriteJsonl, roomFile, rotateAgentToken, setRoomMeta, stashHistory, retrieveHistory, pruneHistory, transportFile, TRANSPORT_DIR, updateJson, } from "./store.js";
|
|
9
|
-
// Resolve the physical file for a (source, agent, channel) tuple.
|
|
10
|
-
function sourceFile(source, agentId, room) {
|
|
11
|
-
if (source === "inbox")
|
|
12
|
-
return inboxFile(agentId);
|
|
13
|
-
if (source === "status")
|
|
14
|
-
return STATUS_FILE;
|
|
15
|
-
return roomFile(room ?? DEFAULT_ROOM);
|
|
16
|
-
}
|
|
17
|
-
// Read/write the cursor offset for a (source, channel). `general` keeps using
|
|
18
|
-
// the flat roomOffset key (hook compat); other channels use roomOffsets[chan].
|
|
19
|
-
function getOffset(cursor, source, room) {
|
|
20
|
-
if (source === "inbox")
|
|
21
|
-
return cursor.inboxOffset ?? 0;
|
|
22
|
-
if (source === "status")
|
|
23
|
-
return cursor.statusOffset ?? 0;
|
|
24
|
-
const chan = normalizeRoom(room);
|
|
25
|
-
return chan === DEFAULT_ROOM ? cursor.roomOffset ?? 0 : cursor.roomOffsets?.[chan] ?? 0;
|
|
26
|
-
}
|
|
27
|
-
function setOffset(cursor, source, room, n) {
|
|
28
|
-
if (source === "inbox") {
|
|
29
|
-
cursor.inboxOffset = n;
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
if (source === "status") {
|
|
33
|
-
cursor.statusOffset = n;
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
const chan = normalizeRoom(room);
|
|
37
|
-
if (chan === DEFAULT_ROOM)
|
|
38
|
-
cursor.roomOffset = n;
|
|
39
|
-
else
|
|
40
|
-
(cursor.roomOffsets ??= {})[chan] = n;
|
|
41
|
-
}
|
|
42
|
-
function sysMsg(from, room, text) {
|
|
43
|
-
return { id: randomUUID(), ts: Date.now(), from, room, text, system: true };
|
|
44
|
-
}
|
|
45
|
-
const STALE_MS = 5 * 60 * 1000;
|
|
46
|
-
const EVICT_MS = 24 * 60 * 60 * 1000;
|
|
47
|
-
const MAX_WAIT_MS = 60_000;
|
|
48
|
-
// ---------- register ----------
|
|
49
|
-
export const registerSchema = {
|
|
50
|
-
agentId: z.string().min(1),
|
|
51
|
-
project: z.string().optional(),
|
|
52
|
-
role: z.string().optional(),
|
|
53
|
-
};
|
|
54
|
-
export async function registerTool(args) {
|
|
55
|
-
const reg = await updateJson(AGENTS_FILE, {}, (current) => {
|
|
56
|
-
const now = Date.now();
|
|
57
|
-
const existing = current[args.agentId];
|
|
58
|
-
current[args.agentId] = {
|
|
59
|
-
agentId: args.agentId,
|
|
60
|
-
project: args.project ?? existing?.project,
|
|
61
|
-
role: args.role ?? existing?.role,
|
|
62
|
-
registeredAt: existing?.registeredAt ?? now,
|
|
63
|
-
lastHeartbeat: now,
|
|
64
|
-
capabilities: existing?.capabilities,
|
|
65
|
-
};
|
|
66
|
-
return current;
|
|
67
|
-
});
|
|
68
|
-
return { ok: true, agent: reg[args.agentId] };
|
|
69
|
-
}
|
|
70
|
-
// ---------- unregister ----------
|
|
71
|
-
export const unregisterSchema = { agentId: z.string().min(1) };
|
|
72
|
-
export async function unregisterTool(args) {
|
|
73
|
-
// If a transport is attached, take it down first so the pusher doesn't keep
|
|
74
|
-
// re-publishing the marker after we drop the registry entry.
|
|
75
|
-
const detach = await detachAgentTool(args);
|
|
76
|
-
let existed = false;
|
|
77
|
-
await updateJson(AGENTS_FILE, {}, (current) => {
|
|
78
|
-
if (current[args.agentId]) {
|
|
79
|
-
existed = true;
|
|
80
|
-
delete current[args.agentId];
|
|
81
|
-
}
|
|
82
|
-
return current;
|
|
83
|
-
});
|
|
84
|
-
// Drop the agent from every channel's membership so it doesn't linger as a
|
|
85
|
-
// ghost in list_rooms / joinedRooms() after it's gone from the registry.
|
|
86
|
-
const leftRooms = [];
|
|
87
|
-
await updateJson(ROOMS_FILE, {}, (current) => {
|
|
88
|
-
for (const [chan, e] of Object.entries(current)) {
|
|
89
|
-
if (e.members?.includes(args.agentId)) {
|
|
90
|
-
e.members = e.members.filter((m) => m !== args.agentId);
|
|
91
|
-
leftRooms.push(chan);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return current;
|
|
95
|
-
});
|
|
96
|
-
return { ok: true, removed: existed, detach, leftRooms };
|
|
97
|
-
}
|
|
98
|
-
// ---------- quit ----------
|
|
99
|
-
// Clean shutdown: unregister (detach transport + leave rooms + remove registry
|
|
100
|
-
// entry) then exit the MCP process. Only the bound identity can call this —
|
|
101
|
-
// prevents one agent from killing another agent's session.
|
|
102
|
-
export const quitSchema = { agentId: z.string().min(1) };
|
|
103
|
-
export async function quitTool(args) {
|
|
104
|
-
await unregisterTool(args);
|
|
105
|
-
// Give stdio a moment to flush the JSON response before exiting
|
|
106
|
-
setTimeout(() => process.exit(0), 150);
|
|
107
|
-
// Return a result so the MCP framework sends the response before the
|
|
108
|
-
// setTimeout fires — the caller will see this before the process dies.
|
|
109
|
-
return { ok: true, message: `'${args.agentId}' unregistered — MCP process exiting` };
|
|
110
|
-
}
|
|
111
|
-
// ---------- heartbeat ----------
|
|
112
|
-
export const heartbeatSchema = { agentId: z.string().min(1) };
|
|
113
|
-
export async function heartbeatTool(args) {
|
|
114
|
-
let missing = false;
|
|
115
|
-
await updateJson(AGENTS_FILE, {}, (current) => {
|
|
116
|
-
if (!current[args.agentId]) {
|
|
117
|
-
missing = true;
|
|
118
|
-
return current;
|
|
119
|
-
}
|
|
120
|
-
current[args.agentId].lastHeartbeat = Date.now();
|
|
121
|
-
return current;
|
|
122
|
-
});
|
|
123
|
-
if (missing)
|
|
124
|
-
return { ok: false, error: `agent '${args.agentId}' not registered` };
|
|
125
|
-
return { ok: true };
|
|
126
|
-
}
|
|
127
|
-
// ---------- list_agents ----------
|
|
128
|
-
export const listAgentsSchema = {};
|
|
129
|
-
export async function listAgentsTool() {
|
|
130
|
-
const now = Date.now();
|
|
131
|
-
const evicted = [];
|
|
132
|
-
// Load live transport markers first so we can refresh heartbeats for agents
|
|
133
|
-
// whose pusher (or other transport daemon) is alive — the live process IS
|
|
134
|
-
// the heartbeat, no separate ping needed.
|
|
135
|
-
const liveTransports = await loadLiveTransports();
|
|
136
|
-
const reg = await updateJson(AGENTS_FILE, {}, (current) => {
|
|
137
|
-
for (const [id, entry] of Object.entries(current)) {
|
|
138
|
-
if (liveTransports.has(id)) {
|
|
139
|
-
entry.lastHeartbeat = now;
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
if (now - entry.lastHeartbeat > EVICT_MS) {
|
|
143
|
-
evicted.push(id);
|
|
144
|
-
delete current[id];
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return current;
|
|
148
|
-
});
|
|
149
|
-
const agents = Object.values(reg).map((a) => {
|
|
150
|
-
const transport = liveTransports.get(a.agentId);
|
|
151
|
-
const merged = [...(a.capabilities ?? [])];
|
|
152
|
-
if (transport && !merged.includes(transport.transport))
|
|
153
|
-
merged.push(transport.transport);
|
|
154
|
-
return {
|
|
155
|
-
...a,
|
|
156
|
-
online: transport ? true : now - a.lastHeartbeat < STALE_MS,
|
|
157
|
-
secondsSinceHeartbeat: Math.floor((now - a.lastHeartbeat) / 1000),
|
|
158
|
-
capabilities: merged.length > 0 ? merged : undefined,
|
|
159
|
-
transport: transport
|
|
160
|
-
? { kind: transport.transport, tmuxTarget: transport.tmuxTarget, pid: transport.pid }
|
|
161
|
-
: undefined,
|
|
162
|
-
};
|
|
163
|
-
});
|
|
164
|
-
return { agents, evicted };
|
|
165
|
-
}
|
|
166
|
-
async function loadLiveTransports() {
|
|
167
|
-
const out = new Map();
|
|
168
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
169
|
-
const now = Date.now();
|
|
170
|
-
for (const fname of await listTransportFiles()) {
|
|
171
|
-
const file = path.join(TRANSPORT_DIR, fname);
|
|
172
|
-
const marker = await readJson(file, null);
|
|
173
|
-
if (!marker || !isMarkerLive(marker, reg, now)) {
|
|
174
|
-
await deleteFile(file);
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
out.set(marker.agentId, marker);
|
|
178
|
-
}
|
|
179
|
-
return out;
|
|
180
|
-
}
|
|
181
|
-
// Liveness for a transport marker. Local markers carry a real pid we can probe;
|
|
182
|
-
// remote markers (tmux-push-remote, pid 0 on a foreign host) can't be — so we
|
|
183
|
-
// trust the registry heartbeat the remote pusher refreshes (within STALE_MS).
|
|
184
|
-
function isMarkerLive(marker, reg, now) {
|
|
185
|
-
if (marker.transport === "tmux-push-remote") {
|
|
186
|
-
const entry = reg[marker.agentId];
|
|
187
|
-
return !!entry && now - entry.lastHeartbeat < STALE_MS;
|
|
188
|
-
}
|
|
189
|
-
return isPidAlive(marker.pid);
|
|
190
|
-
}
|
|
191
|
-
function isPidAlive(pid) {
|
|
192
|
-
if (!pid || pid <= 0)
|
|
193
|
-
return false;
|
|
194
|
-
try {
|
|
195
|
-
process.kill(pid, 0);
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
198
|
-
catch (err) {
|
|
199
|
-
const code = err.code;
|
|
200
|
-
return code === "EPERM"; // EPERM = exists but not ours; ESRCH = gone
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
// ---------- ping ----------
|
|
204
|
-
export const pingSchema = {
|
|
205
|
-
from: z.string().min(1),
|
|
206
|
-
to: z.string().min(1),
|
|
207
|
-
echo: z.boolean().optional(),
|
|
208
|
-
};
|
|
209
|
-
// Liveness probe answered entirely from server-side state — registry entry,
|
|
210
|
-
// transport marker, pusher pid, tmux pane. It never touches the target's
|
|
211
|
-
// session, so a fleet-wide sweep costs zero model tokens on the targets.
|
|
212
|
-
// Distinct from `heartbeat` (the target refreshing its own activity
|
|
213
|
-
// timestamp): ping is a third party asking "would a DM land right now?".
|
|
214
|
-
// echo=true is the one exception — it drops a PING DM into the target's inbox
|
|
215
|
-
// (normal delivery, so the target's model DOES wake); opt-in, default off.
|
|
216
|
-
export async function pingTool(args) {
|
|
217
|
-
const t0 = process.hrtime.bigint();
|
|
218
|
-
const now = Date.now();
|
|
219
|
-
const latencyMs = () => Math.round(Number(process.hrtime.bigint() - t0) / 1e3) / 1e3;
|
|
220
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
221
|
-
const entry = reg[args.to];
|
|
222
|
-
if (!entry) {
|
|
223
|
-
return { ok: true, to: args.to, alive: false, reachable: false, reason: "unregistered", latencyMs: latencyMs() };
|
|
224
|
-
}
|
|
225
|
-
const marker = await readJson(transportFile(args.to), null);
|
|
226
|
-
const heartbeatAgeSec = Math.floor((now - entry.lastHeartbeat) / 1000);
|
|
227
|
-
const heartbeatFresh = now - entry.lastHeartbeat < STALE_MS;
|
|
228
|
-
let transportLive = false;
|
|
229
|
-
let paneAlive;
|
|
230
|
-
if (marker) {
|
|
231
|
-
transportLive = isMarkerLive(marker, reg, now);
|
|
232
|
-
if (transportLive && marker.transport === "tmux-push" && marker.tmuxTarget) {
|
|
233
|
-
// The pusher can outlive its pane (agent window closed) — probe the pane.
|
|
234
|
-
const probe = spawnSync("tmux", ["display-message", "-p", "-t", marker.tmuxTarget, "ok"]);
|
|
235
|
-
paneAlive = probe.status === 0;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
const reachable = transportLive && paneAlive !== false;
|
|
239
|
-
const alive = reachable || heartbeatFresh;
|
|
240
|
-
let echoSent = false;
|
|
241
|
-
if (args.echo && alive) {
|
|
242
|
-
await sendMessageTool({
|
|
243
|
-
from: args.from,
|
|
244
|
-
to: args.to,
|
|
245
|
-
text: `PING: echo requested by ${args.from} — DM back if responsive.`,
|
|
246
|
-
});
|
|
247
|
-
echoSent = true;
|
|
248
|
-
}
|
|
249
|
-
return {
|
|
250
|
-
ok: true,
|
|
251
|
-
to: args.to,
|
|
252
|
-
alive,
|
|
253
|
-
reachable,
|
|
254
|
-
...(alive ? {} : { reason: marker ? "transport-dead" : "heartbeat-stale" }),
|
|
255
|
-
checks: {
|
|
256
|
-
registered: true,
|
|
257
|
-
heartbeatFresh,
|
|
258
|
-
heartbeatAgeSec,
|
|
259
|
-
transport: marker?.transport ?? null,
|
|
260
|
-
transportLive,
|
|
261
|
-
...(paneAlive !== undefined ? { paneAlive, tmuxTarget: marker?.tmuxTarget } : {}),
|
|
262
|
-
},
|
|
263
|
-
...(args.echo ? { echoSent } : {}),
|
|
264
|
-
latencyMs: latencyMs(),
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
// ---------- send_message ----------
|
|
268
|
-
export const sendMessageSchema = {
|
|
269
|
-
from: z.string().min(1),
|
|
270
|
-
to: z.string().optional(),
|
|
271
|
-
room: z.string().optional(),
|
|
272
|
-
text: z.string().min(1),
|
|
273
|
-
};
|
|
274
|
-
export async function sendMessageTool(args) {
|
|
275
|
-
// DM → inbox. Otherwise resolve the channel (default `general`), make sure it
|
|
276
|
-
// exists in the registry, and tag the message with its channel.
|
|
277
|
-
if (args.to) {
|
|
278
|
-
const msg = {
|
|
279
|
-
id: randomUUID(),
|
|
280
|
-
ts: Date.now(),
|
|
281
|
-
from: args.from,
|
|
282
|
-
to: args.to,
|
|
283
|
-
text: args.text,
|
|
284
|
-
};
|
|
285
|
-
const target = inboxFile(args.to);
|
|
286
|
-
await appendJsonl(target, msg);
|
|
287
|
-
// Offline delivery is intentional (the inbox is created on demand), but a
|
|
288
|
-
// typo'd recipient shouldn't vanish silently — surface a warning when the
|
|
289
|
-
// target isn't a known agent so the caller can catch the mistake.
|
|
290
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
291
|
-
const warning = reg[args.to]
|
|
292
|
-
? undefined
|
|
293
|
-
: `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
|
|
294
|
-
return { ok: true, id: msg.id, target, room: undefined, warning };
|
|
295
|
-
}
|
|
296
|
-
const chan = normalizeRoom(args.room);
|
|
297
|
-
if (chan !== DEFAULT_ROOM)
|
|
298
|
-
await ensureRoom(chan, args.from);
|
|
299
|
-
const msg = {
|
|
300
|
-
id: randomUUID(),
|
|
301
|
-
ts: Date.now(),
|
|
302
|
-
from: args.from,
|
|
303
|
-
room: chan,
|
|
304
|
-
text: args.text,
|
|
305
|
-
};
|
|
306
|
-
const target = roomFile(chan);
|
|
307
|
-
await appendJsonl(target, msg);
|
|
308
|
-
return { ok: true, id: msg.id, target, room: chan };
|
|
309
|
-
}
|
|
310
|
-
// ---------- send_command (context-management control commands) ----------
|
|
311
|
-
// The only slash commands a lead may inject into a sub-agent's CLI. Locked on
|
|
312
|
-
// purpose: these wipe/compact context (cheap, reversible-by-the-agent), nothing
|
|
313
|
-
// that mutates the repo or the bus. Stored WITHOUT the leading slash; the wire
|
|
314
|
-
// text is `/${cmd}`.
|
|
315
|
-
export const CONTROL_COMMANDS = ["clear", "compact"];
|
|
316
|
-
// Transports whose pusher can actually TYPE a slash command into a live CLI.
|
|
317
|
-
// A control command is meaningless to a plain MCP poller, so send_command is
|
|
318
|
-
// gated to agents currently attached over one of these.
|
|
319
|
-
const TMUX_TRANSPORTS = new Set(["tmux-push", "tmux-push-remote"]);
|
|
320
|
-
// Normalize "clear" / "/clear" / " /Clear " → "clear"; null if not allowlisted.
|
|
321
|
-
function normalizeControlCommand(raw) {
|
|
322
|
-
const c = raw.trim().replace(/^\/+/, "").toLowerCase();
|
|
323
|
-
return CONTROL_COMMANDS.includes(c) ? c : null;
|
|
324
|
-
}
|
|
325
|
-
// Live transports filtered to the tmux-push family (local + remote).
|
|
326
|
-
async function liveTmuxTargets() {
|
|
327
|
-
const all = await loadLiveTransports();
|
|
328
|
-
const out = new Map();
|
|
329
|
-
for (const [id, m] of all)
|
|
330
|
-
if (TMUX_TRANSPORTS.has(m.transport))
|
|
331
|
-
out.set(id, m);
|
|
332
|
-
return out;
|
|
333
|
-
}
|
|
334
|
-
export const sendCommandSchema = {
|
|
335
|
-
from: z.string().min(1),
|
|
336
|
-
to: z.string().optional(),
|
|
337
|
-
room: z.string().optional(),
|
|
338
|
-
command: z.string().min(1),
|
|
339
|
-
// Default 3000ms. After /clear, schedules an identity-reminder DM to each
|
|
340
|
-
// recipient so a freshly-wiped worker re-anchors on its agentId and bus
|
|
341
|
-
// attach state. Set 0 to opt out. Ignored for non-/clear commands.
|
|
342
|
-
reminderMs: z.number().int().min(0).max(60_000).optional(),
|
|
343
|
-
// Override the auto-generated reminder body if you want something specific.
|
|
344
|
-
reminderText: z.string().optional(),
|
|
345
|
-
// Block until the receiving pusher confirms it actually typed the command
|
|
346
|
-
// into the pane (out-of-band receipt poll — zero added agent context).
|
|
347
|
-
// Default true: a control command you can't confirm is the bug this fixes.
|
|
348
|
-
// Set false for fire-and-forget. The wait is bounded by deliveryTimeoutMs.
|
|
349
|
-
waitForDelivery: z.boolean().optional(),
|
|
350
|
-
deliveryTimeoutMs: z.number().int().min(0).max(30_000).optional(),
|
|
351
|
-
};
|
|
352
|
-
// Poll an agent's receipt log until a receipt for `msgId` appears or the
|
|
353
|
-
// deadline passes. Returns the delivery timestamp, or null on timeout. The
|
|
354
|
-
// receipt is written by the receiver's pusher AFTER send-keys, so a hit is
|
|
355
|
-
// genuine proof the keystrokes reached the pane. File-only — no agent context.
|
|
356
|
-
async function waitForReceipt(agentId, msgId, timeoutMs) {
|
|
357
|
-
const file = receiptFile(agentId);
|
|
358
|
-
const deadline = Date.now() + timeoutMs;
|
|
359
|
-
// First check is immediate; then poll on a short interval.
|
|
360
|
-
for (;;) {
|
|
361
|
-
const receipts = await readJsonl(file);
|
|
362
|
-
const hit = receipts.find((r) => r.id === msgId);
|
|
363
|
-
if (hit)
|
|
364
|
-
return hit.ts ?? Date.now();
|
|
365
|
-
if (Date.now() >= deadline)
|
|
366
|
-
return null;
|
|
367
|
-
await new Promise((res) => setTimeout(res, 150));
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
function defaultReminderText(agentId) {
|
|
371
|
-
return (`[agent-coord] context reset by /clear. ` +
|
|
372
|
-
`Your bus identity is '${agentId}'. You remain registered and attached — call ` +
|
|
373
|
-
`status({agentId:"${agentId}"}) to re-orient (role, transport, unread) and ` +
|
|
374
|
-
`list_rooms() for the channels you're in. Any DM or channel post you receive ` +
|
|
375
|
-
`next is your new task context.`);
|
|
376
|
-
}
|
|
377
|
-
function scheduleReminders(from, recipients, delayMs, override) {
|
|
378
|
-
const t = setTimeout(async () => {
|
|
379
|
-
for (const r of recipients) {
|
|
380
|
-
try {
|
|
381
|
-
const reminder = {
|
|
382
|
-
id: randomUUID(),
|
|
383
|
-
ts: Date.now(),
|
|
384
|
-
from,
|
|
385
|
-
to: r,
|
|
386
|
-
text: override ?? defaultReminderText(r),
|
|
387
|
-
// A just-cleared agent is contextless until this lands — it must
|
|
388
|
-
// push immediately, never queue behind the routine tier.
|
|
389
|
-
urgent: true,
|
|
390
|
-
};
|
|
391
|
-
await appendJsonl(inboxFile(r), reminder);
|
|
392
|
-
}
|
|
393
|
-
catch (e) {
|
|
394
|
-
process.stderr.write(`[send_command] post-/clear reminder to '${r}' failed: ${e?.message ?? e}\n`);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}, delayMs);
|
|
398
|
-
// Don't keep the event loop alive solely for the reminder — the MCP server's
|
|
399
|
-
// transport already holds it open as long as it's connected.
|
|
400
|
-
if (typeof t.unref === "function")
|
|
401
|
-
t.unref();
|
|
402
|
-
}
|
|
403
|
-
// Inject a context-management slash command into a sub-agent's live tmux
|
|
404
|
-
// session. Writes a control-flagged message the pushers deliver RAW (no banner,
|
|
405
|
-
// no `[DM …]` prefix) so the receiving CLI runs it as a real slash command.
|
|
406
|
-
// Hard-gated to tmux: refuses unless the target(s) have a live tmux-push(-remote)
|
|
407
|
-
// transport, so a command never rots unexecuted in an offline inbox.
|
|
408
|
-
export async function sendCommandTool(args) {
|
|
409
|
-
const cmd = normalizeControlCommand(args.command);
|
|
410
|
-
if (!cmd) {
|
|
411
|
-
return {
|
|
412
|
-
ok: false,
|
|
413
|
-
error: `unsupported command '${args.command}'. Allowed: ${CONTROL_COMMANDS.map((c) => "/" + c).join(", ")}`,
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
if (!args.to && !args.room) {
|
|
417
|
-
return { ok: false, error: "specify 'to' (a single agent) or 'room' (a channel's tmux-attached members)" };
|
|
418
|
-
}
|
|
419
|
-
if (args.to && args.room) {
|
|
420
|
-
return { ok: false, error: "specify only one of 'to' or 'room'" };
|
|
421
|
-
}
|
|
422
|
-
const text = `/${cmd}`;
|
|
423
|
-
const liveTmux = await liveTmuxTargets();
|
|
424
|
-
// DM: target must itself be tmux-attached.
|
|
425
|
-
if (args.to) {
|
|
426
|
-
const marker = liveTmux.get(args.to);
|
|
427
|
-
if (!marker) {
|
|
428
|
-
return {
|
|
429
|
-
ok: false,
|
|
430
|
-
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.`,
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
const msg = {
|
|
434
|
-
id: randomUUID(),
|
|
435
|
-
ts: Date.now(),
|
|
436
|
-
from: args.from,
|
|
437
|
-
to: args.to,
|
|
438
|
-
text,
|
|
439
|
-
control: true,
|
|
440
|
-
};
|
|
441
|
-
const target = inboxFile(args.to);
|
|
442
|
-
await appendJsonl(target, msg);
|
|
443
|
-
// Confirm the pusher actually typed it into the pane before we report success
|
|
444
|
-
// (unless explicitly fire-and-forget). Out-of-band receipt poll — the
|
|
445
|
-
// confirmation rides back in THIS tool result, costing no extra agent context.
|
|
446
|
-
const wait = args.waitForDelivery ?? true;
|
|
447
|
-
const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
|
|
448
|
-
const confirmedAt = wait ? await waitForReceipt(args.to, msg.id, deliveryTimeoutMs) : null;
|
|
449
|
-
const confirmed = confirmedAt !== null;
|
|
450
|
-
// After /clear the receiver forgets its identity and that it's bus-attached
|
|
451
|
-
// (the system prompt isn't re-applied because /clear isn't a session
|
|
452
|
-
// start). Schedule a follow-up DM as a re-anchor; opt out with reminderMs:0.
|
|
453
|
-
const reminderMs = cmd === "clear" ? args.reminderMs ?? 3000 : 0;
|
|
454
|
-
if (reminderMs > 0)
|
|
455
|
-
scheduleReminders(args.from, [args.to], reminderMs, args.reminderText);
|
|
456
|
-
return {
|
|
457
|
-
ok: true,
|
|
458
|
-
id: msg.id,
|
|
459
|
-
command: text,
|
|
460
|
-
target,
|
|
461
|
-
delivered: [args.to],
|
|
462
|
-
transport: marker.transport,
|
|
463
|
-
// delivery: confirmed = pusher typed it into the pane; pending = written but
|
|
464
|
-
// unconfirmed within the timeout (stale/wedged pusher — run doctor). Absent
|
|
465
|
-
// when waitForDelivery:false.
|
|
466
|
-
...(wait
|
|
467
|
-
? {
|
|
468
|
-
delivery: confirmed ? "confirmed" : "pending",
|
|
469
|
-
confirmed,
|
|
470
|
-
...(confirmedAt !== null ? { deliveredAt: confirmedAt } : {}),
|
|
471
|
-
...(confirmed
|
|
472
|
-
? {}
|
|
473
|
-
: {
|
|
474
|
-
warning: `no delivery receipt from '${args.to}' within ${deliveryTimeoutMs}ms — the command was written but may not have reached the pane (stale/wedged pusher). Run doctor or re-attach the agent.`,
|
|
475
|
-
}),
|
|
476
|
-
}
|
|
477
|
-
: {}),
|
|
478
|
-
...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: [args.to] } } : {}),
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
// Room: broadcast to every tmux-attached member (never the sender itself).
|
|
482
|
-
const chan = normalizeRoom(args.room);
|
|
483
|
-
const rooms = await getRooms();
|
|
484
|
-
const members = rooms[chan]?.members ?? [];
|
|
485
|
-
const delivered = members.filter((m) => m !== args.from && liveTmux.has(m));
|
|
486
|
-
if (delivered.length === 0) {
|
|
487
|
-
return {
|
|
488
|
-
ok: false,
|
|
489
|
-
error: `no tmux-attached members in #${chan} to receive '${text}' (${members.length} member(s) total). Control commands only fire in a live tmux session.`,
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
const skipped = members.filter((m) => m !== args.from && !liveTmux.has(m));
|
|
493
|
-
const msg = {
|
|
494
|
-
id: randomUUID(),
|
|
495
|
-
ts: Date.now(),
|
|
496
|
-
from: args.from,
|
|
497
|
-
room: chan,
|
|
498
|
-
text,
|
|
499
|
-
control: true,
|
|
500
|
-
};
|
|
501
|
-
const target = roomFile(chan);
|
|
502
|
-
await appendJsonl(target, msg);
|
|
503
|
-
// Confirm each member's pusher typed it in (same msg.id lands in every
|
|
504
|
-
// member's own receipt file). Poll all in parallel within one timeout.
|
|
505
|
-
const wait = args.waitForDelivery ?? true;
|
|
506
|
-
const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
|
|
507
|
-
let confirmed = [];
|
|
508
|
-
let pending = [];
|
|
509
|
-
if (wait) {
|
|
510
|
-
const results = await Promise.all(delivered.map(async (m) => ({ m, at: await waitForReceipt(m, msg.id, deliveryTimeoutMs) })));
|
|
511
|
-
confirmed = results.filter((r) => r.at !== null).map((r) => r.m);
|
|
512
|
-
pending = results.filter((r) => r.at === null).map((r) => r.m);
|
|
513
|
-
}
|
|
514
|
-
// Same post-/clear re-anchor as the DM path — one reminder per delivered
|
|
515
|
-
// member, in their own inbox, with their own agentId in the body.
|
|
516
|
-
const reminderMs = cmd === "clear" ? args.reminderMs ?? 3000 : 0;
|
|
517
|
-
if (reminderMs > 0)
|
|
518
|
-
scheduleReminders(args.from, delivered, reminderMs, args.reminderText);
|
|
519
|
-
return {
|
|
520
|
-
ok: true,
|
|
521
|
-
id: msg.id,
|
|
522
|
-
command: text,
|
|
523
|
-
target,
|
|
524
|
-
room: chan,
|
|
525
|
-
delivered,
|
|
526
|
-
skipped: skipped.length ? skipped : undefined,
|
|
527
|
-
...(wait
|
|
528
|
-
? {
|
|
529
|
-
delivery: pending.length === 0 ? "confirmed" : "partial",
|
|
530
|
-
confirmed,
|
|
531
|
-
...(pending.length
|
|
532
|
-
? {
|
|
533
|
-
pending,
|
|
534
|
-
warning: `no delivery receipt within ${deliveryTimeoutMs}ms from: ${pending.join(", ")} — written but may not have reached their panes (stale/wedged pusher). Run doctor.`,
|
|
535
|
-
}
|
|
536
|
-
: {}),
|
|
537
|
-
}
|
|
538
|
-
: {}),
|
|
539
|
-
...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: delivered } } : {}),
|
|
540
|
-
};
|
|
541
|
-
}
|
|
542
|
-
// ---------- read_messages ----------
|
|
543
|
-
export const readMessagesSchema = {
|
|
544
|
-
agentId: z.string().min(1),
|
|
545
|
-
source: z.enum(["inbox", "room", "status"]),
|
|
546
|
-
room: z.string().optional(),
|
|
547
|
-
limit: z.number().int().positive().max(500).optional(),
|
|
548
|
-
peek: z.boolean().optional(),
|
|
549
|
-
sinceTs: z.number().optional(),
|
|
550
|
-
};
|
|
551
|
-
export async function readMessagesTool(args) {
|
|
552
|
-
const file = sourceFile(args.source, args.agentId, args.room);
|
|
553
|
-
const all = await readJsonl(file);
|
|
554
|
-
let entries = [];
|
|
555
|
-
let totalNew = 0;
|
|
556
|
-
// Room and status reads default to 50 entries to prevent agents flooding
|
|
557
|
-
// themselves with full history on join — the status stream grows unbounded
|
|
558
|
-
// across the fleet. Inbox drains fully since it is targeted by nature.
|
|
559
|
-
const effectiveLimit = args.limit ?? (args.source === "inbox" ? undefined : 50);
|
|
560
|
-
if (args.peek) {
|
|
561
|
-
const cursor = await readJson(cursorFile(args.agentId), {});
|
|
562
|
-
const startOffset = getOffset(cursor, args.source, args.room);
|
|
563
|
-
entries = all.slice(startOffset);
|
|
564
|
-
if (args.sinceTs !== undefined)
|
|
565
|
-
entries = entries.filter((e) => e.ts > args.sinceTs);
|
|
566
|
-
totalNew = entries.length;
|
|
567
|
-
}
|
|
568
|
-
else {
|
|
569
|
-
await updateJson(cursorFile(args.agentId), {}, (current) => {
|
|
570
|
-
const startOffset = getOffset(current, args.source, args.room);
|
|
571
|
-
let e = all.slice(startOffset);
|
|
572
|
-
if (args.sinceTs !== undefined)
|
|
573
|
-
e = e.filter((x) => x.ts > args.sinceTs);
|
|
574
|
-
totalNew = e.length;
|
|
575
|
-
entries = e;
|
|
576
|
-
// Advance past EVERYTHING we account for here (recent window + any
|
|
577
|
-
// overflow we stash below). The overflow is recoverable via the history
|
|
578
|
-
// hash, so it must not requeue for the next read — that would re-flood.
|
|
579
|
-
if (e.length > 0)
|
|
580
|
-
setOffset(current, args.source, args.room, startOffset + e.length);
|
|
581
|
-
return current;
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
// CCR overflow handling (room and status sources). When the backlog exceeds
|
|
585
|
-
// the window, return the RECENT slice raw and replace the older overflow with
|
|
586
|
-
// a compact digest carrying a retrieval hash. The agent expands it on demand
|
|
587
|
-
// via retrieve_room_history. Peek is side-effect-free, so it never stashes —
|
|
588
|
-
// it reports the count and tells the agent to do a real read to get a hash.
|
|
589
|
-
let recent = entries;
|
|
590
|
-
let history;
|
|
591
|
-
if (args.source !== "inbox" && effectiveLimit && entries.length > effectiveLimit) {
|
|
592
|
-
const overflow = entries.slice(0, entries.length - effectiveLimit);
|
|
593
|
-
recent = entries.slice(entries.length - effectiveLimit);
|
|
594
|
-
const stashKey = args.source === "room" ? normalizeRoom(args.room) : "status";
|
|
595
|
-
if (args.peek) {
|
|
596
|
-
history = { digest: digestOverflow(overflow, undefined), older: overflow.length };
|
|
597
|
-
}
|
|
598
|
-
else {
|
|
599
|
-
const hash = await stashHistory(stashKey, args.agentId, overflow);
|
|
600
|
-
history = { digest: digestOverflow(overflow, hash), hash, older: overflow.length };
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
else if (effectiveLimit && entries.length > effectiveLimit) {
|
|
604
|
-
// Inbox keeps the legacy oldest-first chunking (no stash) — targeted
|
|
605
|
-
// messages must never be skipped over.
|
|
606
|
-
recent = entries.slice(0, effectiveLimit);
|
|
607
|
-
}
|
|
608
|
-
// Drop the agent's own posts on shared channels — reading your own broadcast
|
|
609
|
-
// back is never useful and confuses turn-based agents into self-replies.
|
|
610
|
-
// Cursor has already advanced past them, so they won't reappear.
|
|
611
|
-
const visible = args.source === "room" || args.source === "status"
|
|
612
|
-
? recent.filter((e) => entryAuthor(e) !== args.agentId)
|
|
613
|
-
: recent;
|
|
614
|
-
return {
|
|
615
|
-
ok: true,
|
|
616
|
-
messages: visible,
|
|
617
|
-
totalNew,
|
|
618
|
-
returned: visible.length,
|
|
619
|
-
room: args.source === "room" ? normalizeRoom(args.room) : undefined,
|
|
620
|
-
...(history ? { history } : {}),
|
|
621
|
-
};
|
|
622
|
-
}
|
|
623
|
-
// Lossless summary of a stashed backlog slice: surfaces error/failure posts
|
|
624
|
-
// verbatim (the lines that usually matter most in a flood) and collapses the
|
|
625
|
-
// rest to counts. Mirrors headroom's content-aware digest, kept deliberately
|
|
626
|
-
// simple — the full originals are one retrieve_room_history call away.
|
|
627
|
-
function digestOverflow(over, hash) {
|
|
628
|
-
const authors = new Set(over.map(entryAuthor).filter(Boolean));
|
|
629
|
-
const errorRe = /\b(error|fatal|fail(ed|ure)?|panic|exception)\b/i;
|
|
630
|
-
const errors = over.filter((m) => errorRe.test(JSON.stringify(m)));
|
|
631
|
-
const first = over[0]?.ts;
|
|
632
|
-
const last = over[over.length - 1]?.ts;
|
|
633
|
-
const span = first && last && last > first ? ` over ${Math.round((last - first) / 60000)}m` : "";
|
|
634
|
-
const parts = [
|
|
635
|
-
`[${over.length} earlier message${over.length === 1 ? "" : "s"} compressed`,
|
|
636
|
-
`${authors.size} agent${authors.size === 1 ? "" : "s"}${span}`,
|
|
637
|
-
];
|
|
638
|
-
if (errors.length)
|
|
639
|
-
parts.push(`${errors.length} error post${errors.length === 1 ? "" : "s"}`);
|
|
640
|
-
const head = parts.join(", ");
|
|
641
|
-
const tail = hash
|
|
642
|
-
? ` hash=${hash}] — call retrieve_room_history(hash="${hash}") to expand`
|
|
643
|
-
: `] — read without peek to get an expandable hash`;
|
|
644
|
-
return head + tail;
|
|
645
|
-
}
|
|
646
|
-
// ---------- retrieve_room_history ----------
|
|
647
|
-
export const retrieveRoomHistorySchema = {
|
|
648
|
-
agentId: z.string().min(1),
|
|
649
|
-
hash: z.string().min(1),
|
|
650
|
-
query: z.string().optional(),
|
|
651
|
-
};
|
|
652
|
-
export async function retrieveRoomHistoryTool(args) {
|
|
653
|
-
const res = await retrieveHistory(args.hash, args.agentId, args.query);
|
|
654
|
-
if (!res.ok) {
|
|
655
|
-
const reason = res.reason === "expired"
|
|
656
|
-
? "That history entry has expired (30m TTL). Re-read the channel with a higher limit to fetch it again."
|
|
657
|
-
: res.reason === "forbidden"
|
|
658
|
-
? "That history hash was produced for a different agent and cannot be retrieved by you."
|
|
659
|
-
: "No history entry for that hash. It may have expired or never existed.";
|
|
660
|
-
return { ok: false, reason: res.reason, message: reason };
|
|
661
|
-
}
|
|
662
|
-
return {
|
|
663
|
-
ok: true,
|
|
664
|
-
room: res.room,
|
|
665
|
-
hash: args.hash,
|
|
666
|
-
total: res.total,
|
|
667
|
-
returned: res.messages.length,
|
|
668
|
-
messages: res.messages,
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
function entryAuthor(e) {
|
|
672
|
-
return "from" in e ? e.from : e.agentId;
|
|
673
|
-
}
|
|
674
|
-
// ---------- post_status ----------
|
|
675
|
-
export const postStatusSchema = {
|
|
676
|
-
agentId: z.string().min(1),
|
|
677
|
-
status: z.string().min(1),
|
|
678
|
-
detail: z.string().optional(),
|
|
679
|
-
};
|
|
680
|
-
export async function postStatusTool(args) {
|
|
681
|
-
const entry = {
|
|
682
|
-
id: randomUUID(),
|
|
683
|
-
ts: Date.now(),
|
|
684
|
-
agentId: args.agentId,
|
|
685
|
-
status: args.status,
|
|
686
|
-
detail: args.detail,
|
|
687
|
-
};
|
|
688
|
-
await appendJsonl(STATUS_FILE, entry);
|
|
689
|
-
return { ok: true, id: entry.id };
|
|
690
|
-
}
|
|
691
|
-
// ---------- wait_for_message ----------
|
|
692
|
-
export const waitForMessageSchema = {
|
|
693
|
-
agentId: z.string().min(1),
|
|
694
|
-
source: z.enum(["inbox", "room", "status"]),
|
|
695
|
-
room: z.string().optional(),
|
|
696
|
-
timeoutMs: z.number().int().positive().max(MAX_WAIT_MS).optional(),
|
|
697
|
-
};
|
|
698
|
-
export async function waitForMessageTool(args) {
|
|
699
|
-
const totalTimeout = Math.min(args.timeoutMs ?? 30_000, MAX_WAIT_MS);
|
|
700
|
-
const file = sourceFile(args.source, args.agentId, args.room);
|
|
701
|
-
const deadline = Date.now() + totalTimeout;
|
|
702
|
-
// Loop so that file growth caused only by the agent's own self-posts (which
|
|
703
|
-
// readMessagesTool now filters out for room/status) doesn't return an empty
|
|
704
|
-
// result — keep waiting until we have something to deliver or time out.
|
|
705
|
-
while (Date.now() < deadline) {
|
|
706
|
-
const startSize = await fileSize(file);
|
|
707
|
-
const remaining = deadline - Date.now();
|
|
708
|
-
if (remaining <= 0)
|
|
709
|
-
break;
|
|
710
|
-
const changed = await new Promise((resolve) => {
|
|
711
|
-
let settled = false;
|
|
712
|
-
const finish = (v) => {
|
|
713
|
-
if (settled)
|
|
714
|
-
return;
|
|
715
|
-
settled = true;
|
|
716
|
-
clearInterval(poll);
|
|
717
|
-
try {
|
|
718
|
-
watcher?.close();
|
|
719
|
-
}
|
|
720
|
-
catch {
|
|
721
|
-
// ignore
|
|
722
|
-
}
|
|
723
|
-
clearTimeout(t);
|
|
724
|
-
resolve(v);
|
|
725
|
-
};
|
|
726
|
-
const check = async () => {
|
|
727
|
-
const sz = await fileSize(file);
|
|
728
|
-
if (sz > startSize)
|
|
729
|
-
finish(true);
|
|
730
|
-
};
|
|
731
|
-
let watcher;
|
|
732
|
-
try {
|
|
733
|
-
watcher = watch(file, () => {
|
|
734
|
-
void check();
|
|
735
|
-
});
|
|
736
|
-
}
|
|
737
|
-
catch {
|
|
738
|
-
// file may not exist; polling will handle
|
|
739
|
-
}
|
|
740
|
-
const poll = setInterval(() => void check(), 500);
|
|
741
|
-
const t = setTimeout(() => finish(false), remaining);
|
|
742
|
-
});
|
|
743
|
-
if (!changed)
|
|
744
|
-
break;
|
|
745
|
-
const result = await readMessagesTool({ agentId: args.agentId, source: args.source, room: args.room });
|
|
746
|
-
if (result.returned > 0)
|
|
747
|
-
return result;
|
|
748
|
-
// otherwise, only self-posts arrived; keep waiting on the remaining budget
|
|
749
|
-
}
|
|
750
|
-
return { ok: false, timedOut: true };
|
|
751
|
-
}
|
|
752
|
-
// ---------- prune ----------
|
|
753
|
-
export const pruneSchema = {
|
|
754
|
-
olderThanDays: z.number().positive().max(365).optional(),
|
|
755
|
-
removeOrphanInboxes: z.boolean().optional(),
|
|
756
|
-
dryRun: z.boolean().optional(),
|
|
757
|
-
};
|
|
758
|
-
export async function pruneTool(args) {
|
|
759
|
-
const days = args.olderThanDays ?? 7;
|
|
760
|
-
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
761
|
-
const dryRun = args.dryRun ?? false;
|
|
762
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
763
|
-
const knownAgents = new Set(Object.keys(reg));
|
|
764
|
-
const channels = Object.keys(await getRooms());
|
|
765
|
-
if (dryRun) {
|
|
766
|
-
let roomMessages = 0;
|
|
767
|
-
for (const chan of channels) {
|
|
768
|
-
const msgs = await readJsonl(roomFile(chan));
|
|
769
|
-
roomMessages += msgs.filter((e) => e.ts <= cutoff).length;
|
|
770
|
-
}
|
|
771
|
-
const status = await readJsonl(STATUS_FILE);
|
|
772
|
-
const inboxFiles = await listInboxFiles();
|
|
773
|
-
let inboxRemoved = 0;
|
|
774
|
-
const orphans = [];
|
|
775
|
-
for (const fname of inboxFiles) {
|
|
776
|
-
const id = fname.replace(/\.jsonl$/, "");
|
|
777
|
-
if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true))
|
|
778
|
-
orphans.push(fname);
|
|
779
|
-
const entries = await readJsonl(path.join(INBOX_DIR, fname));
|
|
780
|
-
inboxRemoved += entries.filter((e) => e.ts <= cutoff).length;
|
|
781
|
-
}
|
|
782
|
-
const rooms = await getRooms();
|
|
783
|
-
const orphanMembers = new Set();
|
|
784
|
-
for (const e of Object.values(rooms)) {
|
|
785
|
-
for (const m of e.members ?? [])
|
|
786
|
-
if (!knownAgents.has(m))
|
|
787
|
-
orphanMembers.add(m);
|
|
788
|
-
}
|
|
789
|
-
let receiptsRemoved = 0;
|
|
790
|
-
const orphanReceipts = [];
|
|
791
|
-
for (const filePath of await listReceiptFiles()) {
|
|
792
|
-
const id = path.basename(filePath).replace(/\.jsonl$/, "");
|
|
793
|
-
const entries = await readJsonl(filePath);
|
|
794
|
-
if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true))
|
|
795
|
-
orphanReceipts.push(id);
|
|
796
|
-
receiptsRemoved += entries.filter((e) => e.ts <= cutoff).length;
|
|
797
|
-
}
|
|
798
|
-
return {
|
|
799
|
-
dryRun: true,
|
|
800
|
-
cutoff,
|
|
801
|
-
olderThanDays: days,
|
|
802
|
-
wouldRemove: {
|
|
803
|
-
roomMessages,
|
|
804
|
-
statusEntries: status.filter((e) => e.ts <= cutoff).length,
|
|
805
|
-
inboxMessages: inboxRemoved,
|
|
806
|
-
orphanInboxes: orphans,
|
|
807
|
-
orphanMembers: [...orphanMembers],
|
|
808
|
-
receipts: receiptsRemoved,
|
|
809
|
-
orphanReceipts,
|
|
810
|
-
},
|
|
811
|
-
};
|
|
812
|
-
}
|
|
813
|
-
// Per-channel removed counts so we can shift the matching offset in each
|
|
814
|
-
// cursor (default channel → roomOffset, others → roomOffsets[chan]).
|
|
815
|
-
const roomRemovedByChan = {};
|
|
816
|
-
let roomRemovedTotal = 0;
|
|
817
|
-
for (const chan of channels) {
|
|
818
|
-
const r = await rewriteJsonl(roomFile(chan), (e) => e.ts > cutoff);
|
|
819
|
-
if (r.removed > 0) {
|
|
820
|
-
roomRemovedByChan[chan] = r.removed;
|
|
821
|
-
roomRemovedTotal += r.removed;
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
const statusResult = await rewriteJsonl(STATUS_FILE, (e) => e.ts > cutoff);
|
|
825
|
-
const inboxFiles = await listInboxFiles();
|
|
826
|
-
let inboxRemoved = 0;
|
|
827
|
-
const deletedOrphans = [];
|
|
828
|
-
// perAgentInboxRemoved: how many entries were stripped from each *kept* agent's
|
|
829
|
-
// inbox, so we can shift only that agent's inboxOffset cursor below.
|
|
830
|
-
const perAgentInboxRemoved = {};
|
|
831
|
-
for (const fname of inboxFiles) {
|
|
832
|
-
const id = fname.replace(/\.jsonl$/, "");
|
|
833
|
-
const filePath = path.join(INBOX_DIR, fname);
|
|
834
|
-
if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) {
|
|
835
|
-
const entries = await readJsonl(filePath);
|
|
836
|
-
inboxRemoved += entries.length;
|
|
837
|
-
await deleteFile(filePath);
|
|
838
|
-
deletedOrphans.push(id);
|
|
839
|
-
continue;
|
|
840
|
-
}
|
|
841
|
-
const r = await rewriteJsonl(filePath, (e) => e.ts > cutoff);
|
|
842
|
-
inboxRemoved += r.removed;
|
|
843
|
-
perAgentInboxRemoved[id] = r.removed;
|
|
844
|
-
}
|
|
845
|
-
// Cursor adjustment: appendJsonl is time-ordered and rewriteJsonl removes
|
|
846
|
-
// the oldest entries (ts <= cutoff). Any cursor offset shifts down by the
|
|
847
|
-
// removed count, clamped at 0. Without this, an offset past the new file
|
|
848
|
-
// length would make read_messages return [] forever.
|
|
849
|
-
const cursorsAdjusted = [];
|
|
850
|
-
for (const cname of await listCursorFiles()) {
|
|
851
|
-
const id = cname.replace(/\.json$/, "");
|
|
852
|
-
const cursorPath = path.join(CURSOR_DIR, cname);
|
|
853
|
-
let touched = false;
|
|
854
|
-
await updateJson(cursorPath, {}, (current) => {
|
|
855
|
-
for (const [chan, removed] of Object.entries(roomRemovedByChan)) {
|
|
856
|
-
if (chan === DEFAULT_ROOM) {
|
|
857
|
-
if (current.roomOffset !== undefined) {
|
|
858
|
-
current.roomOffset = Math.max(0, current.roomOffset - removed);
|
|
859
|
-
touched = true;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
else if (current.roomOffsets?.[chan] !== undefined) {
|
|
863
|
-
current.roomOffsets[chan] = Math.max(0, current.roomOffsets[chan] - removed);
|
|
864
|
-
touched = true;
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
if (current.statusOffset !== undefined && statusResult.removed > 0) {
|
|
868
|
-
current.statusOffset = Math.max(0, current.statusOffset - statusResult.removed);
|
|
869
|
-
touched = true;
|
|
870
|
-
}
|
|
871
|
-
const myInboxRemoved = perAgentInboxRemoved[id] ?? 0;
|
|
872
|
-
if (current.inboxOffset !== undefined && myInboxRemoved > 0) {
|
|
873
|
-
current.inboxOffset = Math.max(0, current.inboxOffset - myInboxRemoved);
|
|
874
|
-
touched = true;
|
|
875
|
-
}
|
|
876
|
-
return current;
|
|
877
|
-
});
|
|
878
|
-
if (touched)
|
|
879
|
-
cursorsAdjusted.push(id);
|
|
880
|
-
}
|
|
881
|
-
// Compact channel memberships: drop any member no longer in the registry so
|
|
882
|
-
// list_rooms / joinedRooms() stop surfacing ghosts (mirrors orphan-inbox
|
|
883
|
-
// cleanup). Empty non-default channels are left in place — their history may
|
|
884
|
-
// still matter and `general` must always persist.
|
|
885
|
-
const orphanMembers = new Set();
|
|
886
|
-
await updateJson(ROOMS_FILE, {}, (current) => {
|
|
887
|
-
for (const e of Object.values(current)) {
|
|
888
|
-
const before = e.members?.length ?? 0;
|
|
889
|
-
if (before === 0)
|
|
890
|
-
continue;
|
|
891
|
-
e.members = (e.members ?? []).filter((m) => {
|
|
892
|
-
const keep = knownAgents.has(m);
|
|
893
|
-
if (!keep)
|
|
894
|
-
orphanMembers.add(m);
|
|
895
|
-
return keep;
|
|
896
|
-
});
|
|
897
|
-
}
|
|
898
|
-
return current;
|
|
899
|
-
});
|
|
900
|
-
// Receipts are out-of-band proof logs with no cursor — trim old entries by ts
|
|
901
|
-
// and delete logs for agents no longer registered. No offset adjustment needed.
|
|
902
|
-
let receiptsRemoved = 0;
|
|
903
|
-
const deletedOrphanReceipts = [];
|
|
904
|
-
for (const filePath of await listReceiptFiles()) {
|
|
905
|
-
const id = path.basename(filePath).replace(/\.jsonl$/, "");
|
|
906
|
-
if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) {
|
|
907
|
-
const entries = await readJsonl(filePath);
|
|
908
|
-
receiptsRemoved += entries.length;
|
|
909
|
-
await deleteFile(filePath);
|
|
910
|
-
deletedOrphanReceipts.push(id);
|
|
911
|
-
continue;
|
|
912
|
-
}
|
|
913
|
-
const r = await rewriteJsonl(filePath, (e) => e.ts > cutoff);
|
|
914
|
-
receiptsRemoved += r.removed;
|
|
915
|
-
}
|
|
916
|
-
// Sweep expired reversible-history entries (TTL'd cache; also self-prunes on
|
|
917
|
-
// every read, so this just catches entries on a server with few reads).
|
|
918
|
-
await pruneHistory();
|
|
919
|
-
return {
|
|
920
|
-
dryRun: false,
|
|
921
|
-
cutoff,
|
|
922
|
-
olderThanDays: days,
|
|
923
|
-
removed: {
|
|
924
|
-
roomMessages: roomRemovedTotal,
|
|
925
|
-
statusEntries: statusResult.removed,
|
|
926
|
-
inboxMessages: inboxRemoved,
|
|
927
|
-
orphanInboxes: deletedOrphans,
|
|
928
|
-
orphanMembers: [...orphanMembers],
|
|
929
|
-
receipts: receiptsRemoved,
|
|
930
|
-
orphanReceipts: deletedOrphanReceipts,
|
|
931
|
-
},
|
|
932
|
-
cursorsAdjusted,
|
|
933
|
-
};
|
|
934
|
-
}
|
|
935
|
-
// ---------- attach_agent / detach_agent (tmux push transport) ----------
|
|
936
|
-
export const attachAgentSchema = {
|
|
937
|
-
agentId: z.string().min(1),
|
|
938
|
-
tmuxTarget: z.string().optional(),
|
|
939
|
-
includeRoom: z.boolean().optional(),
|
|
940
|
-
allowlist: z.array(z.string()).optional(),
|
|
941
|
-
debounceMs: z.number().int().positive().max(60_000).optional(),
|
|
942
|
-
};
|
|
943
|
-
export async function attachAgentTool(args) {
|
|
944
|
-
// Resolve target: explicit arg > MCP server's own TMUX_PANE env.
|
|
945
|
-
const target = args.tmuxTarget ?? process.env.TMUX_PANE;
|
|
946
|
-
if (!target) {
|
|
947
|
-
return {
|
|
948
|
-
ok: false,
|
|
949
|
-
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').",
|
|
950
|
-
};
|
|
951
|
-
}
|
|
952
|
-
// Validate target exists.
|
|
953
|
-
const probe = spawnSync("tmux", ["display-message", "-p", "-t", target, "ok"]);
|
|
954
|
-
if (probe.status !== 0) {
|
|
955
|
-
return {
|
|
956
|
-
ok: false,
|
|
957
|
-
error: `tmux target '${target}' not found: ${(probe.stderr ?? "").toString().trim()}`,
|
|
958
|
-
};
|
|
959
|
-
}
|
|
960
|
-
// If something's already attached, refuse rather than spawn a second pusher.
|
|
961
|
-
const existing = await readJson(transportFile(args.agentId), null);
|
|
962
|
-
if (existing && isPidAlive(existing.pid)) {
|
|
963
|
-
return {
|
|
964
|
-
ok: false,
|
|
965
|
-
error: `agent '${args.agentId}' already has a live ${existing.transport} attached (pid ${existing.pid}). Call detach_agent first.`,
|
|
966
|
-
existing,
|
|
967
|
-
};
|
|
968
|
-
}
|
|
969
|
-
// Clean up dead marker, if any.
|
|
970
|
-
if (existing)
|
|
971
|
-
await deleteFile(transportFile(args.agentId));
|
|
972
|
-
const pusher = resolvePusherPath();
|
|
973
|
-
if (!existsSync(pusher)) {
|
|
974
|
-
return { ok: false, error: `tmux-pusher not found at ${pusher}` };
|
|
975
|
-
}
|
|
976
|
-
// Detached spawn so the pusher outlives this MCP request/process.
|
|
977
|
-
const log = logFile(args.agentId, "pusher");
|
|
978
|
-
await fsp.mkdir(path.dirname(log), { recursive: true });
|
|
979
|
-
await fsp.mkdir(path.dirname(pidFile(args.agentId, "pusher")), { recursive: true });
|
|
980
|
-
await fsp.mkdir(path.dirname(transportFile(args.agentId)), { recursive: true });
|
|
981
|
-
const logFd = openSync(log, "a");
|
|
982
|
-
// Default: deliver room broadcasts too. The bus is chat-first — silence on
|
|
983
|
-
// a room post is a worse failure mode than a slightly noisier pane. Callers
|
|
984
|
-
// who want DM-only can pass includeRoom:false explicitly.
|
|
985
|
-
const includeRoom = args.includeRoom !== false;
|
|
986
|
-
// Use the exact node binary running this server, not bare "node" — the MCP
|
|
987
|
-
// server is often launched via an absolute path (nvm/Homebrew/bundled
|
|
988
|
-
// runtime) that isn't on the spawned child's PATH, which would silently fail
|
|
989
|
-
// the pusher launch ("attached but nothing arrives").
|
|
990
|
-
const child = spawn(process.execPath, [pusher], {
|
|
991
|
-
detached: true,
|
|
992
|
-
stdio: ["ignore", logFd, logFd],
|
|
993
|
-
env: {
|
|
994
|
-
...process.env,
|
|
995
|
-
AGENT_COORD_ID: args.agentId,
|
|
996
|
-
AGENT_COORD_TMUX_TARGET: target,
|
|
997
|
-
...(includeRoom ? { AGENT_COORD_INCLUDE_ROOM: "1" } : {}),
|
|
998
|
-
...(args.allowlist && args.allowlist.length > 0
|
|
999
|
-
? { AGENT_COORD_ALLOWLIST: args.allowlist.join(",") }
|
|
1000
|
-
: {}),
|
|
1001
|
-
...(args.debounceMs ? { AGENT_COORD_DEBOUNCE_MS: String(args.debounceMs) } : {}),
|
|
1002
|
-
},
|
|
1003
|
-
});
|
|
1004
|
-
child.unref();
|
|
1005
|
-
const pid = child.pid;
|
|
1006
|
-
if (!pid)
|
|
1007
|
-
return { ok: false, error: "spawn returned no pid" };
|
|
1008
|
-
// Write pid file (for scripts) and transport marker (for list_agents).
|
|
1009
|
-
await fsp.writeFile(pidFile(args.agentId, "pusher"), String(pid), "utf8");
|
|
1010
|
-
// Stamp the script's mtime so doctor() can flag a stale daemon if it
|
|
1011
|
-
// outlives a later upgrade of the on-disk script (see v0.8.1 → v0.8.2 bug
|
|
1012
|
-
// report: control commands silently dropped by pre-v0.8 in-memory code).
|
|
1013
|
-
let scriptMtime;
|
|
1014
|
-
try {
|
|
1015
|
-
scriptMtime = (await fsp.stat(pusher)).mtimeMs;
|
|
1016
|
-
}
|
|
1017
|
-
catch { /* non-fatal */ }
|
|
1018
|
-
const marker = {
|
|
1019
|
-
agentId: args.agentId,
|
|
1020
|
-
transport: "tmux-push",
|
|
1021
|
-
pid,
|
|
1022
|
-
tmuxTarget: target,
|
|
1023
|
-
since: Date.now(),
|
|
1024
|
-
scriptMtime,
|
|
1025
|
-
};
|
|
1026
|
-
// Use updateJson so it lockfile-protects and creates the file atomically.
|
|
1027
|
-
await updateJson(transportFile(args.agentId), marker, () => marker);
|
|
1028
|
-
// Best-effort scan for a peek-coord.mjs hook wired to the same agentId —
|
|
1029
|
-
// both consumers share the cursor file and would race / double-deliver.
|
|
1030
|
-
const conflictingHook = await detectPeekCoordHook(args.agentId);
|
|
1031
|
-
return {
|
|
1032
|
-
ok: true,
|
|
1033
|
-
agentId: args.agentId,
|
|
1034
|
-
transport: "tmux-push",
|
|
1035
|
-
tmuxTarget: target,
|
|
1036
|
-
pid,
|
|
1037
|
-
log,
|
|
1038
|
-
...(conflictingHook
|
|
1039
|
-
? {
|
|
1040
|
-
warnings: [
|
|
1041
|
-
`peek-coord.mjs hook for agentId='${args.agentId}' detected in ${conflictingHook}. ` +
|
|
1042
|
-
`Running both transports causes double-delivery — disable one. ` +
|
|
1043
|
-
`Recommend removing the peek-coord hook entry since tmux-push supersedes it.`,
|
|
1044
|
-
],
|
|
1045
|
-
}
|
|
1046
|
-
: {}),
|
|
1047
|
-
};
|
|
1048
|
-
}
|
|
1049
|
-
async function detectPeekCoordHook(agentId) {
|
|
1050
|
-
const home = process.env.HOME ?? "";
|
|
1051
|
-
const cwd = process.cwd();
|
|
1052
|
-
const candidates = [
|
|
1053
|
-
path.join(home, ".claude", "settings.json"),
|
|
1054
|
-
path.join(home, ".claude", "settings.local.json"),
|
|
1055
|
-
path.join(cwd, ".claude", "settings.json"),
|
|
1056
|
-
path.join(cwd, ".claude", "settings.local.json"),
|
|
1057
|
-
];
|
|
1058
|
-
for (const file of candidates) {
|
|
1059
|
-
if (!existsSync(file))
|
|
1060
|
-
continue;
|
|
1061
|
-
try {
|
|
1062
|
-
const raw = await fsp.readFile(file, "utf8");
|
|
1063
|
-
if (raw.includes("peek-coord.mjs") && raw.includes(`AGENT_COORD_ID=${agentId}`)) {
|
|
1064
|
-
return file;
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
catch {
|
|
1068
|
-
// unreadable, skip
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
return undefined;
|
|
1072
|
-
}
|
|
1073
|
-
export const detachAgentSchema = {
|
|
1074
|
-
agentId: z.string().min(1),
|
|
1075
|
-
};
|
|
1076
|
-
export async function detachAgentTool(args) {
|
|
1077
|
-
const marker = await readJson(transportFile(args.agentId), null);
|
|
1078
|
-
let killed = false;
|
|
1079
|
-
if (marker && isPidAlive(marker.pid)) {
|
|
1080
|
-
try {
|
|
1081
|
-
process.kill(marker.pid, "SIGTERM");
|
|
1082
|
-
killed = true;
|
|
1083
|
-
}
|
|
1084
|
-
catch {
|
|
1085
|
-
// already gone
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
await deleteFile(transportFile(args.agentId));
|
|
1089
|
-
await deleteFile(pidFile(args.agentId, "pusher"));
|
|
1090
|
-
return { ok: true, agentId: args.agentId, killed, hadMarker: marker !== null };
|
|
1091
|
-
}
|
|
1092
|
-
function resolvePusherPath() {
|
|
1093
|
-
// tools.js (compiled) lives in dist/; pusher lives in hooks/ at repo root.
|
|
1094
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
1095
|
-
return path.resolve(here, "..", "hooks", "tmux-pusher.mjs");
|
|
1096
|
-
}
|
|
1097
|
-
// ---------- status / whoami ----------
|
|
1098
|
-
export const statusSchema = { agentId: z.string().min(1) };
|
|
1099
|
-
export async function statusTool(args) {
|
|
1100
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
1101
|
-
const entry = reg[args.agentId];
|
|
1102
|
-
const transports = await loadLiveTransports();
|
|
1103
|
-
const transport = transports.get(args.agentId);
|
|
1104
|
-
const inbox = await readJsonl(inboxFile(args.agentId));
|
|
1105
|
-
const cursor = await readJson(cursorFile(args.agentId), {});
|
|
1106
|
-
const inboxOffset = cursor.inboxOffset ?? 0;
|
|
1107
|
-
const unread = Math.max(0, inbox.length - inboxOffset);
|
|
1108
|
-
return {
|
|
1109
|
-
agentId: args.agentId,
|
|
1110
|
-
registered: !!entry,
|
|
1111
|
-
entry,
|
|
1112
|
-
attached: !!transport,
|
|
1113
|
-
transport,
|
|
1114
|
-
inboxDepth: inbox.length,
|
|
1115
|
-
inboxUnread: unread,
|
|
1116
|
-
inTmux: !!process.env.TMUX_PANE,
|
|
1117
|
-
tmuxPane: process.env.TMUX_PANE,
|
|
1118
|
-
};
|
|
1119
|
-
}
|
|
1120
|
-
// ---------- join (combo: register + auto-attach + read inbox) ----------
|
|
1121
|
-
const joinAttachOptionsSchema = z.object({
|
|
1122
|
-
tmuxTarget: z.string().optional(),
|
|
1123
|
-
includeRoom: z.boolean().optional(),
|
|
1124
|
-
allowlist: z.array(z.string()).optional(),
|
|
1125
|
-
debounceMs: z.number().int().positive().max(60_000).optional(),
|
|
1126
|
-
});
|
|
1127
|
-
export const joinSchema = {
|
|
1128
|
-
agentId: z.string().min(1),
|
|
1129
|
-
project: z.string().optional(),
|
|
1130
|
-
role: z.string().optional(),
|
|
1131
|
-
// attach: undefined → auto-attach if $TMUX_PANE is set; true → always try;
|
|
1132
|
-
// false → never; object → attach with overrides.
|
|
1133
|
-
attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
|
|
1134
|
-
readInbox: z.boolean().optional(),
|
|
1135
|
-
};
|
|
1136
|
-
export async function joinTool(args) {
|
|
1137
|
-
const reg = await registerTool({
|
|
1138
|
-
agentId: args.agentId,
|
|
1139
|
-
project: args.project,
|
|
1140
|
-
role: args.role,
|
|
1141
|
-
});
|
|
1142
|
-
// Decide attach behavior.
|
|
1143
|
-
const wantAttach = args.attach === false
|
|
1144
|
-
? false
|
|
1145
|
-
: args.attach === true || typeof args.attach === "object"
|
|
1146
|
-
? true
|
|
1147
|
-
: !!process.env.TMUX_PANE; // undefined → auto-detect
|
|
1148
|
-
// Always present as object | null so callers can branch on a single key
|
|
1149
|
-
// instead of "did I pass attach?" — per agent-pa's API review.
|
|
1150
|
-
let attach = null;
|
|
1151
|
-
if (wantAttach) {
|
|
1152
|
-
const opts = typeof args.attach === "object" ? args.attach : {};
|
|
1153
|
-
attach = await attachAgentTool({ agentId: args.agentId, ...opts });
|
|
1154
|
-
}
|
|
1155
|
-
const readInbox = args.readInbox ?? true;
|
|
1156
|
-
let inbox = null;
|
|
1157
|
-
if (readInbox) {
|
|
1158
|
-
inbox = await readMessagesTool({ agentId: args.agentId, source: "inbox" });
|
|
1159
|
-
}
|
|
1160
|
-
// Surface the default channel's topic + MOTD (room rules) in the same
|
|
1161
|
-
// round-trip, so a connecting agent sees them without a separate call.
|
|
1162
|
-
const rooms = await getRooms();
|
|
1163
|
-
const def = rooms[DEFAULT_ROOM];
|
|
1164
|
-
return {
|
|
1165
|
-
ok: true,
|
|
1166
|
-
registered: reg.agent,
|
|
1167
|
-
attached: !!attach && attach.ok !== false,
|
|
1168
|
-
attach,
|
|
1169
|
-
inbox,
|
|
1170
|
-
defaultRoom: { room: DEFAULT_ROOM, topic: def?.topic, motd: def?.motd },
|
|
1171
|
-
inTmux: !!process.env.TMUX_PANE,
|
|
1172
|
-
};
|
|
1173
|
-
}
|
|
1174
|
-
// ---------- room / channel tools ----------
|
|
1175
|
-
export const listRoomsSchema = {};
|
|
1176
|
-
export async function listRoomsTool() {
|
|
1177
|
-
const reg = await getRooms();
|
|
1178
|
-
const rooms = [];
|
|
1179
|
-
for (const [room, e] of Object.entries(reg)) {
|
|
1180
|
-
const msgs = await readJsonl(roomFile(room));
|
|
1181
|
-
rooms.push({
|
|
1182
|
-
room,
|
|
1183
|
-
topic: e.topic,
|
|
1184
|
-
motd: e.motd,
|
|
1185
|
-
members: e.members ?? [],
|
|
1186
|
-
memberCount: (e.members ?? []).length,
|
|
1187
|
-
messageCount: msgs.length,
|
|
1188
|
-
lastTs: msgs.length ? msgs[msgs.length - 1].ts : undefined,
|
|
1189
|
-
createdAt: e.createdAt,
|
|
1190
|
-
createdBy: e.createdBy,
|
|
1191
|
-
});
|
|
1192
|
-
}
|
|
1193
|
-
return { rooms };
|
|
1194
|
-
}
|
|
1195
|
-
export const joinRoomSchema = {
|
|
1196
|
-
agentId: z.string().min(1),
|
|
1197
|
-
room: z.string().min(1),
|
|
1198
|
-
};
|
|
1199
|
-
export async function joinRoomTool(args) {
|
|
1200
|
-
const chan = normalizeRoom(args.room);
|
|
1201
|
-
await ensureRoom(chan, args.agentId);
|
|
1202
|
-
await addMember(chan, args.agentId);
|
|
1203
|
-
await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `${args.agentId} has joined`));
|
|
1204
|
-
const reg = await getRooms();
|
|
1205
|
-
const e = reg[chan];
|
|
1206
|
-
const all = await readJsonl(roomFile(chan));
|
|
1207
|
-
const cursor = await readJson(cursorFile(args.agentId), {});
|
|
1208
|
-
const unread = Math.max(0, all.length - getOffset(cursor, "room", chan));
|
|
1209
|
-
return {
|
|
1210
|
-
ok: true,
|
|
1211
|
-
room: chan,
|
|
1212
|
-
topic: e?.topic,
|
|
1213
|
-
motd: e?.motd,
|
|
1214
|
-
members: e?.members ?? [],
|
|
1215
|
-
unread,
|
|
1216
|
-
};
|
|
1217
|
-
}
|
|
1218
|
-
export const leaveRoomSchema = {
|
|
1219
|
-
agentId: z.string().min(1),
|
|
1220
|
-
room: z.string().min(1),
|
|
1221
|
-
};
|
|
1222
|
-
export async function leaveRoomTool(args) {
|
|
1223
|
-
const chan = normalizeRoom(args.room);
|
|
1224
|
-
if (chan === DEFAULT_ROOM) {
|
|
1225
|
-
return { ok: false, error: "cannot leave the default channel" };
|
|
1226
|
-
}
|
|
1227
|
-
await removeMember(chan, args.agentId);
|
|
1228
|
-
return { ok: true, room: chan };
|
|
1229
|
-
}
|
|
1230
|
-
export const setRoomTopicSchema = {
|
|
1231
|
-
agentId: z.string().min(1),
|
|
1232
|
-
room: z.string().min(1),
|
|
1233
|
-
topic: z.string(),
|
|
1234
|
-
};
|
|
1235
|
-
export async function setRoomTopicTool(args) {
|
|
1236
|
-
const chan = normalizeRoom(args.room);
|
|
1237
|
-
await ensureRoom(chan, args.agentId);
|
|
1238
|
-
await setRoomMeta(chan, { topic: args.topic }, args.agentId);
|
|
1239
|
-
await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `changed topic to: ${args.topic}`));
|
|
1240
|
-
return { ok: true, room: chan, topic: args.topic };
|
|
1241
|
-
}
|
|
1242
|
-
export const setRoomMotdSchema = {
|
|
1243
|
-
agentId: z.string().min(1),
|
|
1244
|
-
room: z.string().min(1),
|
|
1245
|
-
motd: z.string(),
|
|
1246
|
-
};
|
|
1247
|
-
export async function setRoomMotdTool(args) {
|
|
1248
|
-
const chan = normalizeRoom(args.room);
|
|
1249
|
-
await ensureRoom(chan, args.agentId);
|
|
1250
|
-
await setRoomMeta(chan, { motd: args.motd }, args.agentId);
|
|
1251
|
-
await appendJsonl(roomFile(chan), sysMsg(args.agentId, chan, `updated the room rules (MOTD)`));
|
|
1252
|
-
return { ok: true, room: chan, motd: args.motd };
|
|
1253
|
-
}
|
|
1254
|
-
// ---------- rename_agent (NICK) ----------
|
|
1255
|
-
export const renameAgentSchema = {
|
|
1256
|
-
agentId: z.string().min(1),
|
|
1257
|
-
newAgentId: z.string().min(1),
|
|
1258
|
-
};
|
|
1259
|
-
export async function renameAgentTool(args) {
|
|
1260
|
-
const oldId = args.agentId;
|
|
1261
|
-
const newId = args.newAgentId;
|
|
1262
|
-
if (oldId === newId)
|
|
1263
|
-
return { ok: false, error: "new id is identical to the current id" };
|
|
1264
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
1265
|
-
if (!reg[oldId])
|
|
1266
|
-
return { ok: false, error: `agent '${oldId}' not registered` };
|
|
1267
|
-
if (reg[newId])
|
|
1268
|
-
return { ok: false, error: `agent '${newId}' already exists` };
|
|
1269
|
-
const joined = await memberRooms(oldId);
|
|
1270
|
-
// A running pusher has the OLD agentId (and its file paths) baked into its
|
|
1271
|
-
// env, so after we migrate the inbox/cursor below it would keep tailing the
|
|
1272
|
-
// now-empty old inbox while new DMs land in the new one — silently breaking
|
|
1273
|
-
// delivery and orphaning the moved marker. Take it down first; the caller
|
|
1274
|
-
// must re-attach under the new id (join/attach_agent) to restore push.
|
|
1275
|
-
const liveTransport = await readJson(transportFile(oldId), null);
|
|
1276
|
-
let detachedTransport = false;
|
|
1277
|
-
if (liveTransport && isPidAlive(liveTransport.pid)) {
|
|
1278
|
-
await detachAgentTool({ agentId: oldId });
|
|
1279
|
-
detachedTransport = true;
|
|
1280
|
-
}
|
|
1281
|
-
// Registry: move the entry under the new key.
|
|
1282
|
-
await updateJson(AGENTS_FILE, {}, (current) => {
|
|
1283
|
-
if (current[oldId]) {
|
|
1284
|
-
current[newId] = { ...current[oldId], agentId: newId };
|
|
1285
|
-
delete current[oldId];
|
|
1286
|
-
}
|
|
1287
|
-
return current;
|
|
1288
|
-
});
|
|
1289
|
-
// Channel memberships: rename in place.
|
|
1290
|
-
await updateJson(ROOMS_FILE, {}, (current) => {
|
|
1291
|
-
for (const e of Object.values(current)) {
|
|
1292
|
-
if (e.members?.includes(oldId))
|
|
1293
|
-
e.members = e.members.map((m) => (m === oldId ? newId : m));
|
|
1294
|
-
}
|
|
1295
|
-
return current;
|
|
1296
|
-
});
|
|
1297
|
-
// Per-agent files: inbox, cursor. (The transport marker was already removed
|
|
1298
|
-
// above if a pusher was live; nothing to move otherwise.)
|
|
1299
|
-
await moveFile(inboxFile(oldId), inboxFile(newId));
|
|
1300
|
-
await moveFile(cursorFile(oldId), cursorFile(newId));
|
|
1301
|
-
await moveFile(transportFile(oldId), transportFile(newId));
|
|
1302
|
-
// Identity-binding token rotation: if tokens.json exists and had the old
|
|
1303
|
-
// id, move its token to the new id atomically. Lets the same bearer keep
|
|
1304
|
-
// authenticating after rename — no-op if binding isn't configured.
|
|
1305
|
-
await rotateAgentToken(oldId, newId);
|
|
1306
|
-
// Broadcast a NICK notice to every channel the agent was in.
|
|
1307
|
-
for (const chan of joined) {
|
|
1308
|
-
await appendJsonl(roomFile(chan), sysMsg(newId, chan, `is now known as ${newId} (was ${oldId})`));
|
|
1309
|
-
}
|
|
1310
|
-
return {
|
|
1311
|
-
ok: true,
|
|
1312
|
-
from: oldId,
|
|
1313
|
-
to: newId,
|
|
1314
|
-
rooms: joined,
|
|
1315
|
-
detachedTransport,
|
|
1316
|
-
...(detachedTransport
|
|
1317
|
-
? { warning: `the live tmux-push transport was detached during rename — re-attach as '${newId}' (e.g. join/attach_agent) to restore real-time delivery` }
|
|
1318
|
-
: {}),
|
|
1319
|
-
};
|
|
1320
|
-
}
|
|
1321
|
-
// ---------- transport markers (for remote pushers) ----------
|
|
1322
|
-
export const reportTransportSchema = {
|
|
1323
|
-
agentId: z.string().min(1),
|
|
1324
|
-
transport: z.string().min(1),
|
|
1325
|
-
tmuxTarget: z.string().optional(),
|
|
1326
|
-
host: z.string().optional(),
|
|
1327
|
-
since: z.number().optional(),
|
|
1328
|
-
// mtime of the script the remote daemon loaded into memory at spawn time
|
|
1329
|
-
// (epoch ms). Lets doctor() flag the remote pusher as stale if its on-disk
|
|
1330
|
-
// counterpart has been upgraded since. The remote pusher passes
|
|
1331
|
-
// `(await fsp.stat(__filename)).mtimeMs`; absent → doctor skips the check.
|
|
1332
|
-
scriptMtime: z.number().optional(),
|
|
1333
|
-
};
|
|
1334
|
-
// Called by an external push daemon (typically scripts/coord-pusher.mjs on a
|
|
1335
|
-
// remote machine) to publish a transport marker so list_agents reflects the
|
|
1336
|
-
// attachment. The local tmux-push path writes the marker directly inside
|
|
1337
|
-
// attach_agent; this is the wire-callable equivalent for remote pushers.
|
|
1338
|
-
export async function reportTransportTool(args) {
|
|
1339
|
-
const marker = {
|
|
1340
|
-
agentId: args.agentId,
|
|
1341
|
-
transport: args.transport,
|
|
1342
|
-
pid: 0, // not meaningful for remote; liveness comes from heartbeat
|
|
1343
|
-
tmuxTarget: args.tmuxTarget,
|
|
1344
|
-
host: args.host,
|
|
1345
|
-
since: args.since ?? Date.now(),
|
|
1346
|
-
scriptMtime: args.scriptMtime,
|
|
1347
|
-
};
|
|
1348
|
-
await updateJson(transportFile(args.agentId), marker, () => marker);
|
|
1349
|
-
return { ok: true, marker };
|
|
1350
|
-
}
|
|
1351
|
-
export const clearTransportSchema = {
|
|
1352
|
-
agentId: z.string().min(1),
|
|
1353
|
-
};
|
|
1354
|
-
// Idempotent remote-counterpart to detach_agent: just deletes the marker. Used
|
|
1355
|
-
// by the remote pusher on graceful shutdown so list_agents stops showing it
|
|
1356
|
-
// attached. (Does NOT try to kill any process — there's nothing local to kill.)
|
|
1357
|
-
export async function clearTransportTool(args) {
|
|
1358
|
-
const removed = await deleteFile(transportFile(args.agentId));
|
|
1359
|
-
return { ok: true, removed };
|
|
1360
|
-
}
|
|
1361
|
-
// Count non-empty lines vs successfully-parsed entries in a JSONL file.
|
|
1362
|
-
// Offsets index the PARSED entries (see readJsonl), so `parsed` is the figure
|
|
1363
|
-
// cursor math is compared against; `malformed` is the desync risk.
|
|
1364
|
-
async function scanJsonl(file) {
|
|
1365
|
-
if (!existsSync(file))
|
|
1366
|
-
return { lines: 0, parsed: 0, malformed: 0 };
|
|
1367
|
-
const raw = await fsp.readFile(file, "utf8");
|
|
1368
|
-
let lines = 0;
|
|
1369
|
-
let parsed = 0;
|
|
1370
|
-
for (const line of raw.split("\n")) {
|
|
1371
|
-
if (!line.trim())
|
|
1372
|
-
continue;
|
|
1373
|
-
lines++;
|
|
1374
|
-
try {
|
|
1375
|
-
JSON.parse(line);
|
|
1376
|
-
parsed++;
|
|
1377
|
-
}
|
|
1378
|
-
catch {
|
|
1379
|
-
// malformed
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
return { lines, parsed, malformed: lines - parsed };
|
|
1383
|
-
}
|
|
1384
|
-
// Find leftover proper-lockfile lock dirs (`<file>.lock`) across the state
|
|
1385
|
-
// dirs. Anything older than the threshold is almost certainly orphaned by a
|
|
1386
|
-
// crashed writer (withLock's stale window is 5s).
|
|
1387
|
-
async function scanStaleLocks(olderThanMs, now) {
|
|
1388
|
-
const out = [];
|
|
1389
|
-
const dirs = [ROOT, INBOX_DIR, CURSOR_DIR, ROOMS_DIR, TRANSPORT_DIR];
|
|
1390
|
-
for (const dir of dirs) {
|
|
1391
|
-
if (!existsSync(dir))
|
|
1392
|
-
continue;
|
|
1393
|
-
let names;
|
|
1394
|
-
try {
|
|
1395
|
-
names = await fsp.readdir(dir);
|
|
1396
|
-
}
|
|
1397
|
-
catch {
|
|
1398
|
-
continue;
|
|
1399
|
-
}
|
|
1400
|
-
for (const name of names) {
|
|
1401
|
-
if (!name.endsWith(".lock"))
|
|
1402
|
-
continue;
|
|
1403
|
-
const p = path.join(dir, name);
|
|
1404
|
-
try {
|
|
1405
|
-
const st = await fsp.stat(p);
|
|
1406
|
-
const ageMs = now - st.mtimeMs;
|
|
1407
|
-
if (ageMs > olderThanMs)
|
|
1408
|
-
out.push({ path: p, ageMs });
|
|
1409
|
-
}
|
|
1410
|
-
catch {
|
|
1411
|
-
// vanished mid-scan
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
return out;
|
|
1416
|
-
}
|
|
1417
|
-
export const doctorSchema = {
|
|
1418
|
-
fix: z.boolean().optional(),
|
|
1419
|
-
maxFileBytes: z.number().int().positive().optional(),
|
|
1420
|
-
};
|
|
1421
|
-
export async function doctorTool(args) {
|
|
1422
|
-
const fix = args.fix ?? false;
|
|
1423
|
-
const maxBytes = args.maxFileBytes ?? 5 * 1024 * 1024;
|
|
1424
|
-
const now = Date.now();
|
|
1425
|
-
const findings = [];
|
|
1426
|
-
const fixed = [];
|
|
1427
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
1428
|
-
const known = new Set(Object.keys(reg));
|
|
1429
|
-
const rooms = await getRooms();
|
|
1430
|
-
const channels = Object.keys(rooms);
|
|
1431
|
-
// 1. Orphan transport markers (dead local pid, or stale remote heartbeat).
|
|
1432
|
-
{
|
|
1433
|
-
const dead = [];
|
|
1434
|
-
for (const fname of await listTransportFiles()) {
|
|
1435
|
-
const file = path.join(TRANSPORT_DIR, fname);
|
|
1436
|
-
const marker = await readJson(file, null);
|
|
1437
|
-
if (!marker || !isMarkerLive(marker, reg, now)) {
|
|
1438
|
-
dead.push(file);
|
|
1439
|
-
if (fix) {
|
|
1440
|
-
await deleteFile(file);
|
|
1441
|
-
fixed.push(`deleted stale transport marker ${fname}`);
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
findings.push({
|
|
1446
|
-
check: "orphan-transport-markers",
|
|
1447
|
-
level: dead.length ? "warn" : "ok",
|
|
1448
|
-
detail: dead.length ? `${dead.length} stale transport marker(s) (dead pid or expired remote heartbeat)` : "no stale transport markers",
|
|
1449
|
-
fixable: true,
|
|
1450
|
-
items: dead.length ? dead.map((f) => path.basename(f)) : undefined,
|
|
1451
|
-
});
|
|
1452
|
-
}
|
|
1453
|
-
// 1b. Stale pusher daemons — a long-running pusher loaded its script into
|
|
1454
|
-
// memory at spawn time, so when the on-disk script is later upgraded
|
|
1455
|
-
// (npm i -g a new version), the still-running pid is on the OLD code.
|
|
1456
|
-
// Pre-v0.8.2 pushers had no `control:true` awareness and silently
|
|
1457
|
-
// dropped /clear /compact at the slash-guard — ack:true with no
|
|
1458
|
-
// keystrokes ever reaching the pane. Comparing the marker's stamped
|
|
1459
|
-
// scriptMtime against the on-disk script's current mtime catches it.
|
|
1460
|
-
// Local tmux-push only — for tmux-push-remote the script lives on a
|
|
1461
|
-
// different host so we can't stat it from here.
|
|
1462
|
-
{
|
|
1463
|
-
let localPusherMtime;
|
|
1464
|
-
try {
|
|
1465
|
-
localPusherMtime = (await fsp.stat(resolvePusherPath())).mtimeMs;
|
|
1466
|
-
}
|
|
1467
|
-
catch { /* not packaged? skip */ }
|
|
1468
|
-
const stale = [];
|
|
1469
|
-
for (const fname of await listTransportFiles()) {
|
|
1470
|
-
const file = path.join(TRANSPORT_DIR, fname);
|
|
1471
|
-
const marker = await readJson(file, null);
|
|
1472
|
-
if (!marker || !isMarkerLive(marker, reg, now))
|
|
1473
|
-
continue;
|
|
1474
|
-
if (marker.transport !== "tmux-push")
|
|
1475
|
-
continue; // remote = can't verify
|
|
1476
|
-
if (marker.scriptMtime === undefined)
|
|
1477
|
-
continue; // pre-v0.8.2 marker, no info
|
|
1478
|
-
if (localPusherMtime === undefined)
|
|
1479
|
-
continue;
|
|
1480
|
-
if (marker.scriptMtime < localPusherMtime - 1) { // -1ms slack for fs mtime rounding
|
|
1481
|
-
const loaded = new Date(marker.scriptMtime).toISOString();
|
|
1482
|
-
const ondisk = new Date(localPusherMtime).toISOString();
|
|
1483
|
-
stale.push(`${marker.agentId} (pid ${marker.pid}, loaded ${loaded}, on-disk now ${ondisk})`);
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
findings.push({
|
|
1487
|
-
check: "stale-pusher-script",
|
|
1488
|
-
level: stale.length ? "warn" : "ok",
|
|
1489
|
-
detail: stale.length
|
|
1490
|
-
? `${stale.length} attached pusher(s) running pre-upgrade code — control commands (/clear, /compact) may be silently dropped. Run detach_agent + attach_agent for each, or have the agent relaunch.`
|
|
1491
|
-
: "all attached pushers are running the current on-disk script",
|
|
1492
|
-
fixable: false,
|
|
1493
|
-
items: stale.length ? stale : undefined,
|
|
1494
|
-
});
|
|
1495
|
-
}
|
|
1496
|
-
// 2. Orphan room memberships (member not in the registry).
|
|
1497
|
-
{
|
|
1498
|
-
const orphans = new Set();
|
|
1499
|
-
for (const e of Object.values(rooms)) {
|
|
1500
|
-
for (const m of e.members ?? [])
|
|
1501
|
-
if (!known.has(m))
|
|
1502
|
-
orphans.add(m);
|
|
1503
|
-
}
|
|
1504
|
-
if (fix && orphans.size) {
|
|
1505
|
-
await updateJson(ROOMS_FILE, {}, (cur) => {
|
|
1506
|
-
for (const e of Object.values(cur)) {
|
|
1507
|
-
if (e.members?.length)
|
|
1508
|
-
e.members = e.members.filter((m) => known.has(m));
|
|
1509
|
-
}
|
|
1510
|
-
return cur;
|
|
1511
|
-
});
|
|
1512
|
-
fixed.push(`dropped ${orphans.size} orphan membership(s): ${[...orphans].join(", ")}`);
|
|
1513
|
-
}
|
|
1514
|
-
findings.push({
|
|
1515
|
-
check: "orphan-room-memberships",
|
|
1516
|
-
level: orphans.size ? "warn" : "ok",
|
|
1517
|
-
detail: orphans.size ? `${orphans.size} channel member(s) not in the registry` : "all channel members are registered",
|
|
1518
|
-
fixable: true,
|
|
1519
|
-
items: orphans.size ? [...orphans] : undefined,
|
|
1520
|
-
});
|
|
1521
|
-
}
|
|
1522
|
-
// 3. Orphan inbox / cursor files (owner not registered).
|
|
1523
|
-
{
|
|
1524
|
-
const orphanInbox = [];
|
|
1525
|
-
for (const fname of await listInboxFiles()) {
|
|
1526
|
-
const id = fname.replace(/\.jsonl$/, "");
|
|
1527
|
-
if (!known.has(id)) {
|
|
1528
|
-
orphanInbox.push(id);
|
|
1529
|
-
if (fix) {
|
|
1530
|
-
await deleteFile(path.join(INBOX_DIR, fname));
|
|
1531
|
-
fixed.push(`deleted orphan inbox ${fname}`);
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
const orphanCursor = [];
|
|
1536
|
-
for (const fname of await listCursorFiles()) {
|
|
1537
|
-
const id = fname.replace(/\.json$/, "");
|
|
1538
|
-
if (!known.has(id)) {
|
|
1539
|
-
orphanCursor.push(id);
|
|
1540
|
-
if (fix) {
|
|
1541
|
-
await deleteFile(path.join(CURSOR_DIR, fname));
|
|
1542
|
-
fixed.push(`deleted orphan cursor ${fname}`);
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
const total = orphanInbox.length + orphanCursor.length;
|
|
1547
|
-
findings.push({
|
|
1548
|
-
check: "orphan-inboxes-cursors",
|
|
1549
|
-
level: total ? "warn" : "ok",
|
|
1550
|
-
detail: total
|
|
1551
|
-
? `${orphanInbox.length} inbox + ${orphanCursor.length} cursor file(s) for unregistered ids`
|
|
1552
|
-
: "no orphan inbox/cursor files",
|
|
1553
|
-
fixable: true,
|
|
1554
|
-
items: total ? [...new Set([...orphanInbox, ...orphanCursor])] : undefined,
|
|
1555
|
-
});
|
|
1556
|
-
}
|
|
1557
|
-
// Precompute parsed line counts for cursor + malformed checks.
|
|
1558
|
-
const counts = new Map();
|
|
1559
|
-
const countFor = async (file) => {
|
|
1560
|
-
if (!counts.has(file))
|
|
1561
|
-
counts.set(file, await scanJsonl(file));
|
|
1562
|
-
return counts.get(file);
|
|
1563
|
-
};
|
|
1564
|
-
// 4. Cursor offsets past end-of-file (would return [] forever).
|
|
1565
|
-
{
|
|
1566
|
-
const broken = [];
|
|
1567
|
-
for (const fname of await listCursorFiles()) {
|
|
1568
|
-
const id = fname.replace(/\.json$/, "");
|
|
1569
|
-
const cursorPath = path.join(CURSOR_DIR, fname);
|
|
1570
|
-
const cursor = await readJson(cursorPath, {});
|
|
1571
|
-
const overflow = [];
|
|
1572
|
-
const inboxMax = (await countFor(inboxFile(id))).parsed;
|
|
1573
|
-
if ((cursor.inboxOffset ?? 0) > inboxMax)
|
|
1574
|
-
overflow.push(`inboxOffset ${cursor.inboxOffset}>${inboxMax}`);
|
|
1575
|
-
const roomMax = (await countFor(ROOM_FILE)).parsed;
|
|
1576
|
-
if ((cursor.roomOffset ?? 0) > roomMax)
|
|
1577
|
-
overflow.push(`roomOffset ${cursor.roomOffset}>${roomMax}`);
|
|
1578
|
-
const statusMax = (await countFor(STATUS_FILE)).parsed;
|
|
1579
|
-
if ((cursor.statusOffset ?? 0) > statusMax)
|
|
1580
|
-
overflow.push(`statusOffset ${cursor.statusOffset}>${statusMax}`);
|
|
1581
|
-
for (const [chan, off] of Object.entries(cursor.roomOffsets ?? {})) {
|
|
1582
|
-
const max = (await countFor(roomFile(chan))).parsed;
|
|
1583
|
-
if (off > max)
|
|
1584
|
-
overflow.push(`roomOffsets[${chan}] ${off}>${max}`);
|
|
1585
|
-
}
|
|
1586
|
-
if (overflow.length) {
|
|
1587
|
-
broken.push(`${id}: ${overflow.join(", ")}`);
|
|
1588
|
-
if (fix) {
|
|
1589
|
-
await updateJson(cursorPath, {}, (c) => {
|
|
1590
|
-
if ((c.inboxOffset ?? 0) > inboxMax)
|
|
1591
|
-
c.inboxOffset = inboxMax;
|
|
1592
|
-
if ((c.roomOffset ?? 0) > roomMax)
|
|
1593
|
-
c.roomOffset = roomMax;
|
|
1594
|
-
if ((c.statusOffset ?? 0) > statusMax)
|
|
1595
|
-
c.statusOffset = statusMax;
|
|
1596
|
-
if (c.roomOffsets) {
|
|
1597
|
-
for (const chan of Object.keys(c.roomOffsets)) {
|
|
1598
|
-
const max = counts.get(roomFile(chan))?.parsed ?? 0;
|
|
1599
|
-
if (c.roomOffsets[chan] > max)
|
|
1600
|
-
c.roomOffsets[chan] = max;
|
|
1601
|
-
}
|
|
1602
|
-
}
|
|
1603
|
-
return c;
|
|
1604
|
-
});
|
|
1605
|
-
fixed.push(`clamped cursor offsets for ${id}`);
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
findings.push({
|
|
1610
|
-
check: "cursor-past-eof",
|
|
1611
|
-
level: broken.length ? "error" : "ok",
|
|
1612
|
-
detail: broken.length ? `${broken.length} cursor(s) with an offset past EOF — delivery stalled` : "all cursor offsets are within bounds",
|
|
1613
|
-
fixable: true,
|
|
1614
|
-
items: broken.length ? broken : undefined,
|
|
1615
|
-
});
|
|
1616
|
-
}
|
|
1617
|
-
// 5. Malformed JSONL lines (silently desync offset math between server + hooks).
|
|
1618
|
-
{
|
|
1619
|
-
const jsonlFiles = [
|
|
1620
|
-
ROOM_FILE,
|
|
1621
|
-
STATUS_FILE,
|
|
1622
|
-
...channels.filter((c) => c !== DEFAULT_ROOM).map((c) => roomFile(c)),
|
|
1623
|
-
...(await listInboxFiles()).map((f) => path.join(INBOX_DIR, f)),
|
|
1624
|
-
];
|
|
1625
|
-
const bad = [];
|
|
1626
|
-
for (const file of jsonlFiles) {
|
|
1627
|
-
const c = await countFor(file);
|
|
1628
|
-
if (c.malformed > 0) {
|
|
1629
|
-
bad.push(`${path.basename(file)} (${c.malformed})`);
|
|
1630
|
-
if (fix) {
|
|
1631
|
-
await fsp.copyFile(file, file + ".bak");
|
|
1632
|
-
await rewriteJsonl(file, () => true); // drops unparseable lines
|
|
1633
|
-
fixed.push(`rewrote ${path.basename(file)} dropping ${c.malformed} malformed line(s) (backup: ${path.basename(file)}.bak)`);
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
|
-
findings.push({
|
|
1638
|
-
check: "malformed-jsonl",
|
|
1639
|
-
level: bad.length ? "warn" : "ok",
|
|
1640
|
-
detail: bad.length ? `${bad.length} file(s) contain unparseable lines` : "no malformed JSONL lines",
|
|
1641
|
-
fixable: true,
|
|
1642
|
-
items: bad.length ? bad : undefined,
|
|
1643
|
-
});
|
|
1644
|
-
}
|
|
1645
|
-
// 6. Stale agents (registered, no live transport, heartbeat past EVICT_MS). Report only.
|
|
1646
|
-
{
|
|
1647
|
-
// Compute liveness WITHOUT deleting dead markers — loadLiveTransports
|
|
1648
|
-
// prunes as a side effect, which would make this read-only check mutate
|
|
1649
|
-
// state (and pre-empt the orphan-marker fix in check 1).
|
|
1650
|
-
const live = new Set();
|
|
1651
|
-
for (const fname of await listTransportFiles()) {
|
|
1652
|
-
const marker = await readJson(path.join(TRANSPORT_DIR, fname), null);
|
|
1653
|
-
if (marker && isMarkerLive(marker, reg, now))
|
|
1654
|
-
live.add(marker.agentId);
|
|
1655
|
-
}
|
|
1656
|
-
const stale = [];
|
|
1657
|
-
for (const [id, a] of Object.entries(reg)) {
|
|
1658
|
-
if (live.has(id))
|
|
1659
|
-
continue;
|
|
1660
|
-
if (now - a.lastHeartbeat > EVICT_MS)
|
|
1661
|
-
stale.push(`${id} (${Math.floor((now - a.lastHeartbeat) / 3600000)}h)`);
|
|
1662
|
-
}
|
|
1663
|
-
findings.push({
|
|
1664
|
-
check: "stale-agents",
|
|
1665
|
-
level: stale.length ? "warn" : "ok",
|
|
1666
|
-
detail: stale.length ? `${stale.length} agent(s) past the eviction window — next list_agents will drop them` : "no stale agents",
|
|
1667
|
-
fixable: false,
|
|
1668
|
-
items: stale.length ? stale : undefined,
|
|
1669
|
-
});
|
|
1670
|
-
}
|
|
1671
|
-
// 7. Oversized JSONL files. Report only (suggest prune).
|
|
1672
|
-
{
|
|
1673
|
-
const big = [];
|
|
1674
|
-
const candidates = [
|
|
1675
|
-
ROOM_FILE,
|
|
1676
|
-
STATUS_FILE,
|
|
1677
|
-
...channels.filter((c) => c !== DEFAULT_ROOM).map((c) => roomFile(c)),
|
|
1678
|
-
...(await listInboxFiles()).map((f) => path.join(INBOX_DIR, f)),
|
|
1679
|
-
];
|
|
1680
|
-
for (const file of candidates) {
|
|
1681
|
-
const sz = await fileSize(file);
|
|
1682
|
-
if (sz > maxBytes)
|
|
1683
|
-
big.push(`${path.basename(file)} (${(sz / 1024 / 1024).toFixed(1)}MB)`);
|
|
1684
|
-
}
|
|
1685
|
-
findings.push({
|
|
1686
|
-
check: "oversized-files",
|
|
1687
|
-
level: big.length ? "warn" : "ok",
|
|
1688
|
-
detail: big.length ? `${big.length} file(s) over ${(maxBytes / 1024 / 1024).toFixed(0)}MB — consider prune` : "no oversized files",
|
|
1689
|
-
fixable: false,
|
|
1690
|
-
items: big.length ? big : undefined,
|
|
1691
|
-
});
|
|
1692
|
-
}
|
|
1693
|
-
// 8. Stale lock dirs from crashed writers.
|
|
1694
|
-
{
|
|
1695
|
-
const locks = await scanStaleLocks(60_000, now);
|
|
1696
|
-
for (const l of locks) {
|
|
1697
|
-
if (fix) {
|
|
1698
|
-
try {
|
|
1699
|
-
await fsp.rm(l.path, { recursive: true, force: true });
|
|
1700
|
-
fixed.push(`removed stale lock ${path.basename(l.path)}`);
|
|
1701
|
-
}
|
|
1702
|
-
catch {
|
|
1703
|
-
// ignore
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
findings.push({
|
|
1708
|
-
check: "stale-locks",
|
|
1709
|
-
level: locks.length ? "warn" : "ok",
|
|
1710
|
-
detail: locks.length ? `${locks.length} lock dir(s) older than 60s — likely from a crashed writer` : "no stale locks",
|
|
1711
|
-
fixable: true,
|
|
1712
|
-
items: locks.length ? locks.map((l) => `${path.basename(l.path)} (${Math.floor(l.ageMs / 1000)}s)`) : undefined,
|
|
1713
|
-
});
|
|
1714
|
-
}
|
|
1715
|
-
// 9. Channel/registry consistency: rooms/<chan>.jsonl files without a registry entry.
|
|
1716
|
-
{
|
|
1717
|
-
const orphanFiles = [];
|
|
1718
|
-
if (existsSync(ROOMS_DIR)) {
|
|
1719
|
-
let names = [];
|
|
1720
|
-
try {
|
|
1721
|
-
names = await fsp.readdir(ROOMS_DIR);
|
|
1722
|
-
}
|
|
1723
|
-
catch {
|
|
1724
|
-
// ignore
|
|
1725
|
-
}
|
|
1726
|
-
for (const name of names) {
|
|
1727
|
-
if (!name.endsWith(".jsonl"))
|
|
1728
|
-
continue;
|
|
1729
|
-
const chan = name.replace(/\.jsonl$/, "");
|
|
1730
|
-
if (!rooms[chan]) {
|
|
1731
|
-
orphanFiles.push(name);
|
|
1732
|
-
if (fix) {
|
|
1733
|
-
await ensureRoom(chan, "doctor");
|
|
1734
|
-
fixed.push(`registered channel '${chan}' (had a JSONL file but no registry entry)`);
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
findings.push({
|
|
1740
|
-
check: "channel-registry-consistency",
|
|
1741
|
-
level: orphanFiles.length ? "warn" : "ok",
|
|
1742
|
-
detail: orphanFiles.length ? `${orphanFiles.length} channel file(s) with no registry entry` : "channel files and registry agree",
|
|
1743
|
-
fixable: true,
|
|
1744
|
-
items: orphanFiles.length ? orphanFiles : undefined,
|
|
1745
|
-
});
|
|
1746
|
-
}
|
|
1747
|
-
// 10. Environment sanity. Report only.
|
|
1748
|
-
{
|
|
1749
|
-
const tmuxProbe = spawnSync("tmux", ["-V"]);
|
|
1750
|
-
const tmuxOk = tmuxProbe.status === 0;
|
|
1751
|
-
findings.push({
|
|
1752
|
-
check: "environment",
|
|
1753
|
-
level: tmuxOk ? "ok" : "warn",
|
|
1754
|
-
detail: tmuxOk
|
|
1755
|
-
? `root=${ROOT}; node=${process.execPath}; tmux=${(tmuxProbe.stdout ?? "").toString().trim() || "present"}`
|
|
1756
|
-
: `root=${ROOT}; node=${process.execPath}; tmux NOT on PATH — the tmux-push transport will not work`,
|
|
1757
|
-
fixable: false,
|
|
1758
|
-
items: [`root=${ROOT}`, `execPath=${process.execPath}`, `inTmux=${!!process.env.TMUX_PANE}`],
|
|
1759
|
-
});
|
|
1760
|
-
}
|
|
1761
|
-
const summary = {
|
|
1762
|
-
ok: findings.filter((f) => f.level === "ok").length,
|
|
1763
|
-
warn: findings.filter((f) => f.level === "warn").length,
|
|
1764
|
-
error: findings.filter((f) => f.level === "error").length,
|
|
1765
|
-
};
|
|
1766
|
-
return {
|
|
1767
|
-
ok: true,
|
|
1768
|
-
healthy: summary.warn === 0 && summary.error === 0,
|
|
1769
|
-
fixApplied: fix,
|
|
1770
|
-
root: ROOT,
|
|
1771
|
-
findings,
|
|
1772
|
-
fixed: fix ? fixed : undefined,
|
|
1773
|
-
summary,
|
|
1774
|
-
};
|
|
1775
|
-
}
|
|
1776
|
-
// ---------- delete_room ----------
|
|
1777
|
-
export const deleteRoomSchema = {
|
|
1778
|
-
agentId: z.string().min(1),
|
|
1779
|
-
room: z.string().min(1),
|
|
1780
|
-
// When true, kick remaining members instead of refusing.
|
|
1781
|
-
force: z.boolean().optional(),
|
|
1782
|
-
};
|
|
1783
|
-
export async function deleteRoomTool(args) {
|
|
1784
|
-
const chan = normalizeRoom(args.room);
|
|
1785
|
-
if (chan === DEFAULT_ROOM)
|
|
1786
|
-
return { ok: false, error: "cannot delete the default channel" };
|
|
1787
|
-
const rooms = await getRooms();
|
|
1788
|
-
const entry = rooms[chan];
|
|
1789
|
-
if (!entry)
|
|
1790
|
-
return { ok: false, error: `room '${chan}' does not exist` };
|
|
1791
|
-
const members = entry.members ?? [];
|
|
1792
|
-
if (members.length > 0 && !args.force) {
|
|
1793
|
-
return {
|
|
1794
|
-
ok: false,
|
|
1795
|
-
error: `room '${chan}' still has ${members.length} member(s): ${members.join(", ")}. Pass force=true to delete anyway.`,
|
|
1796
|
-
members,
|
|
1797
|
-
};
|
|
1798
|
-
}
|
|
1799
|
-
// Remove from rooms registry.
|
|
1800
|
-
await updateJson(ROOMS_FILE, {}, (current) => {
|
|
1801
|
-
delete current[chan];
|
|
1802
|
-
return current;
|
|
1803
|
-
});
|
|
1804
|
-
// Delete the backing JSONL file.
|
|
1805
|
-
const file = roomFile(chan);
|
|
1806
|
-
const fileDeleted = await deleteFile(file);
|
|
1807
|
-
// Drop the channel offset from every agent's cursor so no offset points into
|
|
1808
|
-
// a now-deleted file.
|
|
1809
|
-
const cursorsAdjusted = [];
|
|
1810
|
-
for (const fname of await listCursorFiles()) {
|
|
1811
|
-
const id = fname.replace(/\.json$/, "");
|
|
1812
|
-
const cursorPath = path.join(CURSOR_DIR, fname);
|
|
1813
|
-
let touched = false;
|
|
1814
|
-
await updateJson(cursorPath, {}, (current) => {
|
|
1815
|
-
if (current.roomOffsets?.[chan] !== undefined) {
|
|
1816
|
-
delete current.roomOffsets[chan];
|
|
1817
|
-
touched = true;
|
|
1818
|
-
}
|
|
1819
|
-
return current;
|
|
1820
|
-
});
|
|
1821
|
-
if (touched)
|
|
1822
|
-
cursorsAdjusted.push(id);
|
|
1823
|
-
}
|
|
1824
|
-
await appendJsonl(ROOM_FILE, sysMsg(args.agentId, DEFAULT_ROOM, `deleted channel #${chan}`));
|
|
1825
|
-
return { ok: true, room: chan, fileDeleted, members, cursorsAdjusted };
|
|
1826
|
-
}
|
|
1827
|
-
// ---------- force_unregister ----------
|
|
1828
|
-
export const forceUnregisterSchema = {
|
|
1829
|
-
targetAgentId: z.string().min(1),
|
|
1830
|
-
};
|
|
1831
|
-
// Admin eviction — same logic as unregister but bypasses the identity gate
|
|
1832
|
-
// so the caller does not need to be the target agent.
|
|
1833
|
-
export async function forceUnregisterTool(args) {
|
|
1834
|
-
return unregisterTool({ agentId: args.targetAgentId });
|
|
1835
|
-
}
|
|
1836
|
-
// ---------- helpers ----------
|
|
1837
|
-
async function moveFile(from, to) {
|
|
1838
|
-
if (!existsSync(from))
|
|
1839
|
-
return false;
|
|
1840
|
-
await fsp.mkdir(path.dirname(to), { recursive: true });
|
|
1841
|
-
try {
|
|
1842
|
-
await fsp.rename(from, to);
|
|
1843
|
-
}
|
|
1844
|
-
catch {
|
|
1845
|
-
// Cross-device or other rename failure — fall back to copy + unlink.
|
|
1846
|
-
const data = await fsp.readFile(from);
|
|
1847
|
-
await fsp.writeFile(to, data);
|
|
1848
|
-
await fsp.unlink(from);
|
|
1849
|
-
}
|
|
1850
|
-
return true;
|
|
1851
|
-
}
|
|
1852
|
-
//# sourceMappingURL=tools.js.map
|