agent-coord-mcp 0.9.0 → 0.13.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 +6 -2
- package/dist/server.js +4 -2
- package/dist/server.js.map +1 -1
- package/dist/store.js +82 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +161 -16
- package/dist/tools.js.map +1 -1
- package/hooks/tier.mjs +92 -0
- package/hooks/tmux-pusher.mjs +116 -30
- package/package.json +1 -1
- package/scripts/coord-chat.mjs +59 -2
- package/src/server.ts +19 -1
- package/src/store.ts +107 -1
- package/src/tools.ts +184 -14
package/scripts/coord-chat.mjs
CHANGED
|
@@ -43,6 +43,9 @@ import lockfile from "proper-lockfile";
|
|
|
43
43
|
const args = parseArgs(process.argv.slice(2));
|
|
44
44
|
let ID = args.id ?? process.env.USER ?? "human"; // reassignable so /nick can rebind it
|
|
45
45
|
const ROOT = args.dir ?? process.env.AGENT_COORD_DIR ?? path.join(homedir(), "agent-coord");
|
|
46
|
+
// Pin the env so a dynamically-imported dist tool (e.g. /doctor's doctorTool)
|
|
47
|
+
// binds store.js's ROOT to the same dir when --dir overrode the default.
|
|
48
|
+
process.env.AGENT_COORD_DIR = ROOT;
|
|
46
49
|
|
|
47
50
|
// Message-rendering state + helpers. Declared up here (above the top-level
|
|
48
51
|
// printRecent() call) so they're initialized before first use — const/let
|
|
@@ -186,6 +189,15 @@ const AGENT_COLORS = [
|
|
|
186
189
|
];
|
|
187
190
|
// Will be initialized after ROOT is set, just below.
|
|
188
191
|
|
|
192
|
+
// ---------- non-interactive --doctor ----------
|
|
193
|
+
// `coord-chat --doctor [--fix]` runs the same health check as the MCP doctor
|
|
194
|
+
// tool and prints its report, then exits — no registration, no TUI. Same
|
|
195
|
+
// renderer as the /doctor slash command, so CLI and in-chat output match.
|
|
196
|
+
if (args.doctor) {
|
|
197
|
+
const ok = await runDoctor(!!args.fix, (l) => process.stdout.write(l + "\n"));
|
|
198
|
+
process.exit(ok ? 0 : 1);
|
|
199
|
+
}
|
|
200
|
+
|
|
189
201
|
// ---------- register and start UI ----------
|
|
190
202
|
|
|
191
203
|
await register();
|
|
@@ -219,7 +231,7 @@ const SLASH_COMMANDS = [
|
|
|
219
231
|
"/dm", "/msg", "/list", "/who", "/whoami", "/whois", "/last", "/find",
|
|
220
232
|
"/clear", "/cls", "/me", "/status", "/away", "/back", "/ignore", "/unignore",
|
|
221
233
|
"/nick", "/join", "/part", "/leave", "/rooms", "/channels", "/topic", "/motd",
|
|
222
|
-
"/rules", "/prune", "/kick", "/wipe-room",
|
|
234
|
+
"/rules", "/prune", "/kick", "/wipe-room", "/doctor",
|
|
223
235
|
"/help", "/?", "/quit", "/exit",
|
|
224
236
|
];
|
|
225
237
|
|
|
@@ -442,6 +454,9 @@ async function handleLine(line) {
|
|
|
442
454
|
const term = text.slice(6).trim();
|
|
443
455
|
if (!term) say(A.red("usage: /find <text>"));
|
|
444
456
|
else await findInHistory(term);
|
|
457
|
+
} else if (text === "/doctor" || text.startsWith("/doctor ")) {
|
|
458
|
+
const fix = text.slice(7).trim() === "--fix";
|
|
459
|
+
await runDoctor(fix, say);
|
|
445
460
|
} else if (text.startsWith("/")) {
|
|
446
461
|
say(A.red(`unknown command: ${text.split(" ")[0]}`) + A.dim(" (try /help)"));
|
|
447
462
|
} else {
|
|
@@ -477,10 +492,13 @@ function parseArgs(argv) {
|
|
|
477
492
|
for (let i = 0; i < argv.length; i++) {
|
|
478
493
|
if (argv[i] === "--id") out.id = argv[++i];
|
|
479
494
|
else if (argv[i] === "--dir") out.dir = argv[++i];
|
|
495
|
+
else if (argv[i] === "--doctor") out.doctor = true;
|
|
496
|
+
else if (argv[i] === "--fix") out.fix = true;
|
|
480
497
|
else if (argv[i] === "-h" || argv[i] === "--help") {
|
|
481
498
|
console.log("coord-chat — minimal TUI for agent-coord-mcp");
|
|
482
499
|
console.log("usage: coord-chat [--id <name>] [--dir <path>]");
|
|
483
|
-
console.log("
|
|
500
|
+
console.log(" coord-chat --doctor [--fix] health-check coord state, print report, exit");
|
|
501
|
+
console.log("at prompt: <text>=room /dm <id> <text> /list /doctor /quit");
|
|
484
502
|
process.exit(0);
|
|
485
503
|
}
|
|
486
504
|
}
|
|
@@ -624,6 +642,7 @@ function printHelp() {
|
|
|
624
642
|
["/prune [days]", "drop messages older than N days (default 7)"],
|
|
625
643
|
["/kick <agent>", "unregister an agent + kill their pusher"],
|
|
626
644
|
["/wipe-room", "truncate the current channel (destructive)"],
|
|
645
|
+
["/doctor [--fix]", "health-check coord state (--fix auto-remediates)"],
|
|
627
646
|
[A.dim("---"), ""],
|
|
628
647
|
["/help, /?", "this list"],
|
|
629
648
|
["/quit [msg], /exit", "unregister and leave"],
|
|
@@ -735,6 +754,44 @@ async function postStatus(status) {
|
|
|
735
754
|
say(A.dim(`→ status posted: ${status}`));
|
|
736
755
|
}
|
|
737
756
|
|
|
757
|
+
// Run the shared doctor health check and render its structured report through
|
|
758
|
+
// `emit` (say for the TUI, console for CLI). Delegates to the compiled MCP
|
|
759
|
+
// doctorTool so the output is the single source of truth — no reimplemented,
|
|
760
|
+
// drift-prone checks. Returns true when nothing is at error level. `emit` is
|
|
761
|
+
// injected so the same renderer serves both /doctor and `--doctor`.
|
|
762
|
+
async function runDoctor(fix, emit) {
|
|
763
|
+
let doctorTool;
|
|
764
|
+
try {
|
|
765
|
+
({ doctorTool } = await import(new URL("../dist/tools.js", import.meta.url)));
|
|
766
|
+
} catch (e) {
|
|
767
|
+
emit(A.red(`/doctor unavailable: could not load the doctor tool (${e?.message ?? e}).`));
|
|
768
|
+
emit(A.dim(" This build may be missing dist/ — run `npm run build` in the package."));
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
const icon = { ok: A.green("✓"), warn: A.yellow("!"), error: A.red("✗") };
|
|
772
|
+
const res = await doctorTool({ fix });
|
|
773
|
+
emit(
|
|
774
|
+
A.bold("coord doctor") +
|
|
775
|
+
A.dim(` root=${res.root}`) +
|
|
776
|
+
(fix ? A.dim(" (--fix applied)") : ""),
|
|
777
|
+
);
|
|
778
|
+
for (const f of res.findings) {
|
|
779
|
+
emit(` ${icon[f.level] ?? "?"} ${A.bold(f.check)} ${f.detail}`);
|
|
780
|
+
for (const item of f.items ?? []) emit(A.dim(` - ${item}`));
|
|
781
|
+
}
|
|
782
|
+
for (const done of res.fixed ?? []) emit(A.green(` fixed: ${done}`));
|
|
783
|
+
const s = res.summary;
|
|
784
|
+
emit(
|
|
785
|
+
A.dim(" ") +
|
|
786
|
+
`${A.green(`${s.ok} ok`)} ${A.yellow(`${s.warn} warn`)} ${A.red(`${s.error} error`)}` +
|
|
787
|
+
(res.healthy ? A.green(" — healthy") : A.red(" — needs attention")),
|
|
788
|
+
);
|
|
789
|
+
if (!fix && res.findings.some((f) => f.fixable && f.level !== "ok")) {
|
|
790
|
+
emit(A.dim(" run /doctor --fix to auto-remediate fixable findings"));
|
|
791
|
+
}
|
|
792
|
+
return res.summary.error === 0;
|
|
793
|
+
}
|
|
794
|
+
|
|
738
795
|
async function pruneOld(days) {
|
|
739
796
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
740
797
|
let total = 0;
|
package/src/server.ts
CHANGED
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
leaveRoomTool,
|
|
29
29
|
listAgentsSchema,
|
|
30
30
|
listAgentsTool,
|
|
31
|
+
pingSchema,
|
|
32
|
+
pingTool,
|
|
31
33
|
listRoomsSchema,
|
|
32
34
|
listRoomsTool,
|
|
33
35
|
postStatusSchema,
|
|
@@ -36,6 +38,8 @@ import {
|
|
|
36
38
|
pruneTool,
|
|
37
39
|
readMessagesSchema,
|
|
38
40
|
readMessagesTool,
|
|
41
|
+
retrieveRoomHistorySchema,
|
|
42
|
+
retrieveRoomHistoryTool,
|
|
39
43
|
registerSchema,
|
|
40
44
|
registerTool,
|
|
41
45
|
renameAgentSchema,
|
|
@@ -166,6 +170,13 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
166
170
|
gate("agentId", heartbeatTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
167
171
|
);
|
|
168
172
|
|
|
173
|
+
server.tool(
|
|
174
|
+
"ping",
|
|
175
|
+
"Liveness probe for another agent, answered entirely from server-side state (registry entry, transport marker, pusher pid, tmux pane) — it never touches the target's session, so a fleet-wide sweep costs zero model tokens on the targets. Returns alive (fresh heartbeat or live transport), reachable (a DM pushed now would land), granular checks, and latencyMs. Distinct from heartbeat, which is an agent refreshing its OWN activity timestamp. Pass echo:true (default off) to additionally drop a PING DM into the target's inbox — that wakes the target's model, so use it sparingly and only when you need an agent-level acknowledgement. 'from' is enforced against the session's bound identity.",
|
|
176
|
+
pingSchema,
|
|
177
|
+
gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
178
|
+
);
|
|
179
|
+
|
|
169
180
|
server.tool(
|
|
170
181
|
"list_agents",
|
|
171
182
|
"List all known agents and whether they appear online (heartbeat <5min).",
|
|
@@ -189,11 +200,18 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
189
200
|
|
|
190
201
|
server.tool(
|
|
191
202
|
"read_messages",
|
|
192
|
-
"Read new messages from inbox|room|status. For source='room', pass 'room' to read a specific channel (default 'general'). Room reads
|
|
203
|
+
"Read new messages from inbox|room|status. For source='room', pass 'room' to read a specific channel (default 'general'). Room and status reads return the most recent 50 entries per call — pass limit to override (max 500). When the backlog exceeds the window, the older overflow is replaced by a compact `history` digest carrying a retrieval hash; call retrieve_room_history(hash) to expand it. Inbox drains fully by default. Advances the per-channel cursor unless peek=true.",
|
|
193
204
|
readMessagesSchema,
|
|
194
205
|
gate("agentId", readMessagesTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
195
206
|
);
|
|
196
207
|
|
|
208
|
+
server.tool(
|
|
209
|
+
"retrieve_room_history",
|
|
210
|
+
"Expand a compressed channel-history digest returned by read_messages. Pass the `hash` from the `history` field; optionally pass `query` to return only matching messages (case-insensitive substring). Entries are scoped to the agent that produced them and expire after 30 minutes — if expired, re-read the channel with a higher limit instead.",
|
|
211
|
+
retrieveRoomHistorySchema,
|
|
212
|
+
gate("agentId", retrieveRoomHistoryTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
213
|
+
);
|
|
214
|
+
|
|
197
215
|
server.tool(
|
|
198
216
|
"post_status",
|
|
199
217
|
"Append a status broadcast to the shared status stream.",
|
package/src/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { promises as fs, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import lockfile from "proper-lockfile";
|
|
@@ -23,6 +24,18 @@ export const LOG_DIR = path.join(ROOT, "logs");
|
|
|
23
24
|
// `written-to-jsonl:true`, at zero added agent token cost.
|
|
24
25
|
export const RECEIPTS_DIR = path.join(ROOT, "receipts");
|
|
25
26
|
|
|
27
|
+
// Reversible-history cache (CCR pattern, v0.9.x). When read_messages would
|
|
28
|
+
// flood an agent with a large channel backlog, the overflow (everything older
|
|
29
|
+
// than the recent window) is stashed here as one entry and replaced inline by
|
|
30
|
+
// a compact digest carrying a `hash`. The agent expands it on demand via
|
|
31
|
+
// retrieve_room_history(hash). Entries are content+scope addressed, TTL'd, and
|
|
32
|
+
// scoped to the (room, agent) they were produced for so a hash can't be
|
|
33
|
+
// replayed to read a channel the caller never read itself. This is a cache of
|
|
34
|
+
// data ALREADY present in rooms/<chan>.jsonl — not a new source of truth — so
|
|
35
|
+
// losing it (TTL/eviction) only costs the agent a re-read at a higher limit.
|
|
36
|
+
export const HISTORY_DIR = path.join(ROOT, "history");
|
|
37
|
+
export const HISTORY_TTL_MS = 30 * 60_000; // session-scale; mirrors headroom DEFAULT_CCR_TTL_SECONDS
|
|
38
|
+
|
|
26
39
|
// Channels. The default channel `general` keeps using the legacy single-room
|
|
27
40
|
// file (room.jsonl) + the flat `roomOffset` cursor key, so existing agents and
|
|
28
41
|
// the notification hooks keep working with zero migration. Every other channel
|
|
@@ -40,7 +53,7 @@ export const DEFAULT_ROOM = "general";
|
|
|
40
53
|
export const TOKENS_FILE = path.join(ROOT, "tokens.json");
|
|
41
54
|
|
|
42
55
|
export function ensureDirs(): void {
|
|
43
|
-
for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR]) {
|
|
56
|
+
for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR]) {
|
|
44
57
|
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
45
58
|
}
|
|
46
59
|
for (const f of [ROOM_FILE, STATUS_FILE]) {
|
|
@@ -349,3 +362,96 @@ export async function fileSize(file: string): Promise<number> {
|
|
|
349
362
|
const st = await fs.stat(file);
|
|
350
363
|
return st.size;
|
|
351
364
|
}
|
|
365
|
+
|
|
366
|
+
// ---------- reversible history cache (CCR) ----------
|
|
367
|
+
|
|
368
|
+
export type HistoryEntry<T = unknown> = {
|
|
369
|
+
hash: string;
|
|
370
|
+
room: string;
|
|
371
|
+
forAgent: string; // scope key — only this agent may retrieve (see HISTORY_DIR note)
|
|
372
|
+
createdTs: number;
|
|
373
|
+
messages: T[];
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
function historyFile(hash: string): string {
|
|
377
|
+
return path.join(HISTORY_DIR, `${sanitize(hash)}.json`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Content+scope address: same backlog read by two agents (or twice by one)
|
|
381
|
+
// yields distinct entries, and the hash can't be guessed for a (room, agent)
|
|
382
|
+
// pair the caller never read. 12 hex chars ≈ 48 bits — collision-safe at this
|
|
383
|
+
// volume, short enough to sit inline in the digest marker.
|
|
384
|
+
function historyHash(room: string, forAgent: string, messages: { ts: number }[]): string {
|
|
385
|
+
const first = messages[0]?.ts ?? 0;
|
|
386
|
+
const last = messages[messages.length - 1]?.ts ?? 0;
|
|
387
|
+
const sig = `${room}${forAgent}${first}${last}${messages.length}`;
|
|
388
|
+
return createHash("sha1").update(sig).digest("hex").slice(0, 12);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Best-effort sweep of expired entries. Cheap (one readdir + stat per file) and
|
|
392
|
+
// only the data is a disposable cache, so we swallow all errors.
|
|
393
|
+
export async function pruneHistory(now = Date.now()): Promise<void> {
|
|
394
|
+
if (!existsSync(HISTORY_DIR)) return;
|
|
395
|
+
let names: string[];
|
|
396
|
+
try {
|
|
397
|
+
names = await fs.readdir(HISTORY_DIR);
|
|
398
|
+
} catch {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
await Promise.all(
|
|
402
|
+
names
|
|
403
|
+
.filter((n) => n.endsWith(".json"))
|
|
404
|
+
.map(async (n) => {
|
|
405
|
+
const f = path.join(HISTORY_DIR, n);
|
|
406
|
+
try {
|
|
407
|
+
const e = await readJsonNoLock<HistoryEntry | null>(f, null);
|
|
408
|
+
if (!e || now - e.createdTs > HISTORY_TTL_MS) await fs.unlink(f).catch(() => {});
|
|
409
|
+
} catch {
|
|
410
|
+
/* leave it; next sweep retries */
|
|
411
|
+
}
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Stash a backlog slice and return its hash. Caller embeds the hash in the
|
|
417
|
+
// digest marker it returns to the agent.
|
|
418
|
+
export async function stashHistory<T extends { ts: number }>(
|
|
419
|
+
room: string,
|
|
420
|
+
forAgent: string,
|
|
421
|
+
messages: T[],
|
|
422
|
+
): Promise<string> {
|
|
423
|
+
const hash = historyHash(room, forAgent, messages);
|
|
424
|
+
const entry: HistoryEntry<T> = { hash, room, forAgent, createdTs: Date.now(), messages };
|
|
425
|
+
await writeJson(historyFile(hash), entry);
|
|
426
|
+
void pruneHistory();
|
|
427
|
+
return hash;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export type RetrieveHistoryResult<T = unknown> =
|
|
431
|
+
| { ok: true; room: string; total: number; messages: T[] }
|
|
432
|
+
| { ok: false; reason: "not_found" | "expired" | "forbidden" };
|
|
433
|
+
|
|
434
|
+
// Expand a stashed backlog. Enforces the (forAgent) scope and TTL. `query`, if
|
|
435
|
+
// given, returns only entries whose serialized form contains the substring
|
|
436
|
+
// (case-insensitive) — the lossless analogue of headroom's BM25 search-within.
|
|
437
|
+
export async function retrieveHistory<T = unknown>(
|
|
438
|
+
hash: string,
|
|
439
|
+
forAgent: string,
|
|
440
|
+
query?: string,
|
|
441
|
+
): Promise<RetrieveHistoryResult<T>> {
|
|
442
|
+
const f = historyFile(hash);
|
|
443
|
+
const entry = await readJson<HistoryEntry<T> | null>(f, null);
|
|
444
|
+
if (!entry) return { ok: false, reason: "not_found" };
|
|
445
|
+
if (Date.now() - entry.createdTs > HISTORY_TTL_MS) {
|
|
446
|
+
await deleteFile(f).catch(() => {});
|
|
447
|
+
return { ok: false, reason: "expired" };
|
|
448
|
+
}
|
|
449
|
+
if (entry.forAgent !== forAgent) return { ok: false, reason: "forbidden" };
|
|
450
|
+
|
|
451
|
+
let messages = entry.messages;
|
|
452
|
+
if (query && query.trim()) {
|
|
453
|
+
const q = query.toLowerCase();
|
|
454
|
+
messages = messages.filter((m) => JSON.stringify(m).toLowerCase().includes(q));
|
|
455
|
+
}
|
|
456
|
+
return { ok: true, room: entry.room, total: entry.messages.length, messages };
|
|
457
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -39,6 +39,9 @@ import {
|
|
|
39
39
|
roomFile,
|
|
40
40
|
rotateAgentToken,
|
|
41
41
|
setRoomMeta,
|
|
42
|
+
stashHistory,
|
|
43
|
+
retrieveHistory,
|
|
44
|
+
pruneHistory,
|
|
42
45
|
transportFile,
|
|
43
46
|
TRANSPORT_DIR,
|
|
44
47
|
updateJson,
|
|
@@ -86,6 +89,10 @@ type Message = {
|
|
|
86
89
|
// its operator. The tmux pushers inject these RAW (no banner/prefix) so the
|
|
87
90
|
// TUI runs them as real slash commands; every other consumer ignores them.
|
|
88
91
|
control?: boolean;
|
|
92
|
+
// Server-generated push-now override (e.g. the post-/clear identity
|
|
93
|
+
// reminder). Only server code can set this — send_message constructs the
|
|
94
|
+
// Message from fixed fields, so peers cannot smuggle it in.
|
|
95
|
+
urgent?: boolean;
|
|
89
96
|
};
|
|
90
97
|
|
|
91
98
|
type StatusEntry = {
|
|
@@ -312,6 +319,79 @@ function isPidAlive(pid: number): boolean {
|
|
|
312
319
|
}
|
|
313
320
|
}
|
|
314
321
|
|
|
322
|
+
// ---------- ping ----------
|
|
323
|
+
|
|
324
|
+
export const pingSchema = {
|
|
325
|
+
from: z.string().min(1),
|
|
326
|
+
to: z.string().min(1),
|
|
327
|
+
echo: z.boolean().optional(),
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Liveness probe answered entirely from server-side state — registry entry,
|
|
331
|
+
// transport marker, pusher pid, tmux pane. It never touches the target's
|
|
332
|
+
// session, so a fleet-wide sweep costs zero model tokens on the targets.
|
|
333
|
+
// Distinct from `heartbeat` (the target refreshing its own activity
|
|
334
|
+
// timestamp): ping is a third party asking "would a DM land right now?".
|
|
335
|
+
// echo=true is the one exception — it drops a PING DM into the target's inbox
|
|
336
|
+
// (normal delivery, so the target's model DOES wake); opt-in, default off.
|
|
337
|
+
export async function pingTool(args: { from: string; to: string; echo?: boolean }) {
|
|
338
|
+
const t0 = process.hrtime.bigint();
|
|
339
|
+
const now = Date.now();
|
|
340
|
+
const latencyMs = () => Math.round(Number(process.hrtime.bigint() - t0) / 1e3) / 1e3;
|
|
341
|
+
|
|
342
|
+
const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
|
|
343
|
+
const entry = reg[args.to];
|
|
344
|
+
if (!entry) {
|
|
345
|
+
return { ok: true, to: args.to, alive: false, reachable: false, reason: "unregistered", latencyMs: latencyMs() };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const marker = await readJson<TransportMarker | null>(transportFile(args.to), null);
|
|
349
|
+
const heartbeatAgeSec = Math.floor((now - entry.lastHeartbeat) / 1000);
|
|
350
|
+
const heartbeatFresh = now - entry.lastHeartbeat < STALE_MS;
|
|
351
|
+
|
|
352
|
+
let transportLive = false;
|
|
353
|
+
let paneAlive: boolean | undefined;
|
|
354
|
+
if (marker) {
|
|
355
|
+
transportLive = isMarkerLive(marker, reg, now);
|
|
356
|
+
if (transportLive && marker.transport === "tmux-push" && marker.tmuxTarget) {
|
|
357
|
+
// The pusher can outlive its pane (agent window closed) — probe the pane.
|
|
358
|
+
const probe = spawnSync("tmux", ["display-message", "-p", "-t", marker.tmuxTarget, "ok"]);
|
|
359
|
+
paneAlive = probe.status === 0;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const reachable = transportLive && paneAlive !== false;
|
|
364
|
+
const alive = reachable || heartbeatFresh;
|
|
365
|
+
|
|
366
|
+
let echoSent = false;
|
|
367
|
+
if (args.echo && alive) {
|
|
368
|
+
await sendMessageTool({
|
|
369
|
+
from: args.from,
|
|
370
|
+
to: args.to,
|
|
371
|
+
text: `PING: echo requested by ${args.from} — DM back if responsive.`,
|
|
372
|
+
});
|
|
373
|
+
echoSent = true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
ok: true,
|
|
378
|
+
to: args.to,
|
|
379
|
+
alive,
|
|
380
|
+
reachable,
|
|
381
|
+
...(alive ? {} : { reason: marker ? "transport-dead" : "heartbeat-stale" }),
|
|
382
|
+
checks: {
|
|
383
|
+
registered: true,
|
|
384
|
+
heartbeatFresh,
|
|
385
|
+
heartbeatAgeSec,
|
|
386
|
+
transport: marker?.transport ?? null,
|
|
387
|
+
transportLive,
|
|
388
|
+
...(paneAlive !== undefined ? { paneAlive, tmuxTarget: marker?.tmuxTarget } : {}),
|
|
389
|
+
},
|
|
390
|
+
...(args.echo ? { echoSent } : {}),
|
|
391
|
+
latencyMs: latencyMs(),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
315
395
|
// ---------- send_message ----------
|
|
316
396
|
|
|
317
397
|
export const sendMessageSchema = {
|
|
@@ -455,6 +535,9 @@ function scheduleReminders(
|
|
|
455
535
|
from,
|
|
456
536
|
to: r,
|
|
457
537
|
text: override ?? defaultReminderText(r),
|
|
538
|
+
// A just-cleared agent is contextless until this lands — it must
|
|
539
|
+
// push immediately, never queue behind the routine tier.
|
|
540
|
+
urgent: true,
|
|
458
541
|
};
|
|
459
542
|
await appendJsonl(inboxFile(r), reminder);
|
|
460
543
|
} catch (e) {
|
|
@@ -643,40 +726,65 @@ export async function readMessagesTool(args: {
|
|
|
643
726
|
const file = sourceFile(args.source, args.agentId, args.room);
|
|
644
727
|
const all = await readJsonl<Message | StatusEntry>(file);
|
|
645
728
|
|
|
646
|
-
let
|
|
729
|
+
let entries: (Message | StatusEntry)[] = [];
|
|
647
730
|
let totalNew = 0;
|
|
648
731
|
|
|
649
|
-
// Room reads default to 50
|
|
650
|
-
// with full
|
|
651
|
-
// since
|
|
652
|
-
const effectiveLimit = args.limit ?? (args.source === "
|
|
732
|
+
// Room and status reads default to 50 entries to prevent agents flooding
|
|
733
|
+
// themselves with full history on join — the status stream grows unbounded
|
|
734
|
+
// across the fleet. Inbox drains fully since it is targeted by nature.
|
|
735
|
+
const effectiveLimit = args.limit ?? (args.source === "inbox" ? undefined : 50);
|
|
653
736
|
|
|
654
737
|
if (args.peek) {
|
|
655
738
|
const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
|
|
656
739
|
const startOffset = getOffset(cursor, args.source, args.room);
|
|
657
|
-
|
|
740
|
+
entries = all.slice(startOffset);
|
|
658
741
|
if (args.sinceTs !== undefined) entries = entries.filter((e) => e.ts > args.sinceTs!);
|
|
659
742
|
totalNew = entries.length;
|
|
660
|
-
limited = effectiveLimit ? entries.slice(0, effectiveLimit) : entries;
|
|
661
743
|
} else {
|
|
662
744
|
await updateJson<Cursor>(cursorFile(args.agentId), {}, (current) => {
|
|
663
745
|
const startOffset = getOffset(current, args.source, args.room);
|
|
664
|
-
let
|
|
665
|
-
if (args.sinceTs !== undefined)
|
|
666
|
-
totalNew =
|
|
667
|
-
|
|
668
|
-
|
|
746
|
+
let e = all.slice(startOffset);
|
|
747
|
+
if (args.sinceTs !== undefined) e = e.filter((x) => x.ts > args.sinceTs!);
|
|
748
|
+
totalNew = e.length;
|
|
749
|
+
entries = e;
|
|
750
|
+
// Advance past EVERYTHING we account for here (recent window + any
|
|
751
|
+
// overflow we stash below). The overflow is recoverable via the history
|
|
752
|
+
// hash, so it must not requeue for the next read — that would re-flood.
|
|
753
|
+
if (e.length > 0) setOffset(current, args.source, args.room, startOffset + e.length);
|
|
669
754
|
return current;
|
|
670
755
|
});
|
|
671
756
|
}
|
|
672
757
|
|
|
758
|
+
// CCR overflow handling (room and status sources). When the backlog exceeds
|
|
759
|
+
// the window, return the RECENT slice raw and replace the older overflow with
|
|
760
|
+
// a compact digest carrying a retrieval hash. The agent expands it on demand
|
|
761
|
+
// via retrieve_room_history. Peek is side-effect-free, so it never stashes —
|
|
762
|
+
// it reports the count and tells the agent to do a real read to get a hash.
|
|
763
|
+
let recent = entries;
|
|
764
|
+
let history: { digest: string; hash?: string; older: number } | undefined;
|
|
765
|
+
if (args.source !== "inbox" && effectiveLimit && entries.length > effectiveLimit) {
|
|
766
|
+
const overflow = entries.slice(0, entries.length - effectiveLimit);
|
|
767
|
+
recent = entries.slice(entries.length - effectiveLimit);
|
|
768
|
+
const stashKey = args.source === "room" ? normalizeRoom(args.room) : "status";
|
|
769
|
+
if (args.peek) {
|
|
770
|
+
history = { digest: digestOverflow(overflow, undefined), older: overflow.length };
|
|
771
|
+
} else {
|
|
772
|
+
const hash = await stashHistory(stashKey, args.agentId, overflow);
|
|
773
|
+
history = { digest: digestOverflow(overflow, hash), hash, older: overflow.length };
|
|
774
|
+
}
|
|
775
|
+
} else if (effectiveLimit && entries.length > effectiveLimit) {
|
|
776
|
+
// Inbox keeps the legacy oldest-first chunking (no stash) — targeted
|
|
777
|
+
// messages must never be skipped over.
|
|
778
|
+
recent = entries.slice(0, effectiveLimit);
|
|
779
|
+
}
|
|
780
|
+
|
|
673
781
|
// Drop the agent's own posts on shared channels — reading your own broadcast
|
|
674
782
|
// back is never useful and confuses turn-based agents into self-replies.
|
|
675
783
|
// Cursor has already advanced past them, so they won't reappear.
|
|
676
784
|
const visible =
|
|
677
785
|
args.source === "room" || args.source === "status"
|
|
678
|
-
?
|
|
679
|
-
:
|
|
786
|
+
? recent.filter((e) => entryAuthor(e) !== args.agentId)
|
|
787
|
+
: recent;
|
|
680
788
|
|
|
681
789
|
return {
|
|
682
790
|
ok: true,
|
|
@@ -684,6 +792,64 @@ export async function readMessagesTool(args: {
|
|
|
684
792
|
totalNew,
|
|
685
793
|
returned: visible.length,
|
|
686
794
|
room: args.source === "room" ? normalizeRoom(args.room) : undefined,
|
|
795
|
+
...(history ? { history } : {}),
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Lossless summary of a stashed backlog slice: surfaces error/failure posts
|
|
800
|
+
// verbatim (the lines that usually matter most in a flood) and collapses the
|
|
801
|
+
// rest to counts. Mirrors headroom's content-aware digest, kept deliberately
|
|
802
|
+
// simple — the full originals are one retrieve_room_history call away.
|
|
803
|
+
function digestOverflow(over: (Message | StatusEntry)[], hash: string | undefined): string {
|
|
804
|
+
const authors = new Set(over.map(entryAuthor).filter(Boolean));
|
|
805
|
+
const errorRe = /\b(error|fatal|fail(ed|ure)?|panic|exception)\b/i;
|
|
806
|
+
const errors = over.filter((m) => errorRe.test(JSON.stringify(m)));
|
|
807
|
+
const first = over[0]?.ts;
|
|
808
|
+
const last = over[over.length - 1]?.ts;
|
|
809
|
+
const span =
|
|
810
|
+
first && last && last > first ? ` over ${Math.round((last - first) / 60000)}m` : "";
|
|
811
|
+
const parts = [
|
|
812
|
+
`[${over.length} earlier message${over.length === 1 ? "" : "s"} compressed`,
|
|
813
|
+
`${authors.size} agent${authors.size === 1 ? "" : "s"}${span}`,
|
|
814
|
+
];
|
|
815
|
+
if (errors.length) parts.push(`${errors.length} error post${errors.length === 1 ? "" : "s"}`);
|
|
816
|
+
const head = parts.join(", ");
|
|
817
|
+
const tail = hash
|
|
818
|
+
? ` hash=${hash}] — call retrieve_room_history(hash="${hash}") to expand`
|
|
819
|
+
: `] — read without peek to get an expandable hash`;
|
|
820
|
+
return head + tail;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ---------- retrieve_room_history ----------
|
|
824
|
+
|
|
825
|
+
export const retrieveRoomHistorySchema = {
|
|
826
|
+
agentId: z.string().min(1),
|
|
827
|
+
hash: z.string().min(1),
|
|
828
|
+
query: z.string().optional(),
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
export async function retrieveRoomHistoryTool(args: {
|
|
832
|
+
agentId: string;
|
|
833
|
+
hash: string;
|
|
834
|
+
query?: string;
|
|
835
|
+
}) {
|
|
836
|
+
const res = await retrieveHistory<Message | StatusEntry>(args.hash, args.agentId, args.query);
|
|
837
|
+
if (!res.ok) {
|
|
838
|
+
const reason =
|
|
839
|
+
res.reason === "expired"
|
|
840
|
+
? "That history entry has expired (30m TTL). Re-read the channel with a higher limit to fetch it again."
|
|
841
|
+
: res.reason === "forbidden"
|
|
842
|
+
? "That history hash was produced for a different agent and cannot be retrieved by you."
|
|
843
|
+
: "No history entry for that hash. It may have expired or never existed.";
|
|
844
|
+
return { ok: false, reason: res.reason, message: reason };
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
ok: true,
|
|
848
|
+
room: res.room,
|
|
849
|
+
hash: args.hash,
|
|
850
|
+
total: res.total,
|
|
851
|
+
returned: res.messages.length,
|
|
852
|
+
messages: res.messages,
|
|
687
853
|
};
|
|
688
854
|
}
|
|
689
855
|
|
|
@@ -950,6 +1116,10 @@ export async function pruneTool(args: {
|
|
|
950
1116
|
receiptsRemoved += r.removed;
|
|
951
1117
|
}
|
|
952
1118
|
|
|
1119
|
+
// Sweep expired reversible-history entries (TTL'd cache; also self-prunes on
|
|
1120
|
+
// every read, so this just catches entries on a server with few reads).
|
|
1121
|
+
await pruneHistory();
|
|
1122
|
+
|
|
953
1123
|
return {
|
|
954
1124
|
dryRun: false,
|
|
955
1125
|
cutoff,
|