agent-coord-mcp 0.8.10 → 0.10.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 +11 -2
- package/dist/server.js +4 -3
- package/dist/server.js.map +1 -1
- package/dist/store.js +100 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +187 -12
- package/dist/tools.js.map +1 -1
- package/hooks/tmux-pusher.mjs +40 -5
- package/package.json +1 -2
- package/scripts/coord-pusher.mjs +5 -5
- package/src/server.ts +11 -2
- package/src/store.ts +126 -1
- package/src/tools.ts +206 -10
package/src/tools.ts
CHANGED
|
@@ -32,11 +32,16 @@ import {
|
|
|
32
32
|
pidFile,
|
|
33
33
|
readJson,
|
|
34
34
|
readJsonl,
|
|
35
|
+
receiptFile,
|
|
36
|
+
listReceiptFiles,
|
|
35
37
|
removeMember,
|
|
36
38
|
rewriteJsonl,
|
|
37
39
|
roomFile,
|
|
38
40
|
rotateAgentToken,
|
|
39
41
|
setRoomMeta,
|
|
42
|
+
stashHistory,
|
|
43
|
+
retrieveHistory,
|
|
44
|
+
pruneHistory,
|
|
40
45
|
transportFile,
|
|
41
46
|
TRANSPORT_DIR,
|
|
42
47
|
updateJson,
|
|
@@ -399,8 +404,35 @@ export const sendCommandSchema = {
|
|
|
399
404
|
reminderMs: z.number().int().min(0).max(60_000).optional(),
|
|
400
405
|
// Override the auto-generated reminder body if you want something specific.
|
|
401
406
|
reminderText: z.string().optional(),
|
|
407
|
+
// Block until the receiving pusher confirms it actually typed the command
|
|
408
|
+
// into the pane (out-of-band receipt poll — zero added agent context).
|
|
409
|
+
// Default true: a control command you can't confirm is the bug this fixes.
|
|
410
|
+
// Set false for fire-and-forget. The wait is bounded by deliveryTimeoutMs.
|
|
411
|
+
waitForDelivery: z.boolean().optional(),
|
|
412
|
+
deliveryTimeoutMs: z.number().int().min(0).max(30_000).optional(),
|
|
402
413
|
};
|
|
403
414
|
|
|
415
|
+
// Poll an agent's receipt log until a receipt for `msgId` appears or the
|
|
416
|
+
// deadline passes. Returns the delivery timestamp, or null on timeout. The
|
|
417
|
+
// receipt is written by the receiver's pusher AFTER send-keys, so a hit is
|
|
418
|
+
// genuine proof the keystrokes reached the pane. File-only — no agent context.
|
|
419
|
+
async function waitForReceipt(
|
|
420
|
+
agentId: string,
|
|
421
|
+
msgId: string,
|
|
422
|
+
timeoutMs: number,
|
|
423
|
+
): Promise<number | null> {
|
|
424
|
+
const file = receiptFile(agentId);
|
|
425
|
+
const deadline = Date.now() + timeoutMs;
|
|
426
|
+
// First check is immediate; then poll on a short interval.
|
|
427
|
+
for (;;) {
|
|
428
|
+
const receipts = await readJsonl<{ id: string; ts: number }>(file);
|
|
429
|
+
const hit = receipts.find((r) => r.id === msgId);
|
|
430
|
+
if (hit) return hit.ts ?? Date.now();
|
|
431
|
+
if (Date.now() >= deadline) return null;
|
|
432
|
+
await new Promise((res) => setTimeout(res, 150));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
404
436
|
function defaultReminderText(agentId: string): string {
|
|
405
437
|
return (
|
|
406
438
|
`[agent-coord] context reset by /clear. ` +
|
|
@@ -452,6 +484,8 @@ export async function sendCommandTool(args: {
|
|
|
452
484
|
command: string;
|
|
453
485
|
reminderMs?: number;
|
|
454
486
|
reminderText?: string;
|
|
487
|
+
waitForDelivery?: boolean;
|
|
488
|
+
deliveryTimeoutMs?: number;
|
|
455
489
|
}) {
|
|
456
490
|
const cmd = normalizeControlCommand(args.command);
|
|
457
491
|
if (!cmd) {
|
|
@@ -489,6 +523,13 @@ export async function sendCommandTool(args: {
|
|
|
489
523
|
};
|
|
490
524
|
const target = inboxFile(args.to);
|
|
491
525
|
await appendJsonl(target, msg);
|
|
526
|
+
// Confirm the pusher actually typed it into the pane before we report success
|
|
527
|
+
// (unless explicitly fire-and-forget). Out-of-band receipt poll — the
|
|
528
|
+
// confirmation rides back in THIS tool result, costing no extra agent context.
|
|
529
|
+
const wait = args.waitForDelivery ?? true;
|
|
530
|
+
const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
|
|
531
|
+
const confirmedAt = wait ? await waitForReceipt(args.to, msg.id, deliveryTimeoutMs) : null;
|
|
532
|
+
const confirmed = confirmedAt !== null;
|
|
492
533
|
// After /clear the receiver forgets its identity and that it's bus-attached
|
|
493
534
|
// (the system prompt isn't re-applied because /clear isn't a session
|
|
494
535
|
// start). Schedule a follow-up DM as a re-anchor; opt out with reminderMs:0.
|
|
@@ -501,6 +542,21 @@ export async function sendCommandTool(args: {
|
|
|
501
542
|
target,
|
|
502
543
|
delivered: [args.to],
|
|
503
544
|
transport: marker.transport,
|
|
545
|
+
// delivery: confirmed = pusher typed it into the pane; pending = written but
|
|
546
|
+
// unconfirmed within the timeout (stale/wedged pusher — run doctor). Absent
|
|
547
|
+
// when waitForDelivery:false.
|
|
548
|
+
...(wait
|
|
549
|
+
? {
|
|
550
|
+
delivery: confirmed ? "confirmed" : "pending",
|
|
551
|
+
confirmed,
|
|
552
|
+
...(confirmedAt !== null ? { deliveredAt: confirmedAt } : {}),
|
|
553
|
+
...(confirmed
|
|
554
|
+
? {}
|
|
555
|
+
: {
|
|
556
|
+
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.`,
|
|
557
|
+
}),
|
|
558
|
+
}
|
|
559
|
+
: {}),
|
|
504
560
|
...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: [args.to] } } : {}),
|
|
505
561
|
};
|
|
506
562
|
}
|
|
@@ -527,6 +583,19 @@ export async function sendCommandTool(args: {
|
|
|
527
583
|
};
|
|
528
584
|
const target = roomFile(chan);
|
|
529
585
|
await appendJsonl(target, msg);
|
|
586
|
+
// Confirm each member's pusher typed it in (same msg.id lands in every
|
|
587
|
+
// member's own receipt file). Poll all in parallel within one timeout.
|
|
588
|
+
const wait = args.waitForDelivery ?? true;
|
|
589
|
+
const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
|
|
590
|
+
let confirmed: string[] = [];
|
|
591
|
+
let pending: string[] = [];
|
|
592
|
+
if (wait) {
|
|
593
|
+
const results = await Promise.all(
|
|
594
|
+
delivered.map(async (m) => ({ m, at: await waitForReceipt(m, msg.id, deliveryTimeoutMs) })),
|
|
595
|
+
);
|
|
596
|
+
confirmed = results.filter((r) => r.at !== null).map((r) => r.m);
|
|
597
|
+
pending = results.filter((r) => r.at === null).map((r) => r.m);
|
|
598
|
+
}
|
|
530
599
|
// Same post-/clear re-anchor as the DM path — one reminder per delivered
|
|
531
600
|
// member, in their own inbox, with their own agentId in the body.
|
|
532
601
|
const reminderMs = cmd === "clear" ? args.reminderMs ?? 3000 : 0;
|
|
@@ -539,6 +608,18 @@ export async function sendCommandTool(args: {
|
|
|
539
608
|
room: chan,
|
|
540
609
|
delivered,
|
|
541
610
|
skipped: skipped.length ? skipped : undefined,
|
|
611
|
+
...(wait
|
|
612
|
+
? {
|
|
613
|
+
delivery: pending.length === 0 ? "confirmed" : "partial",
|
|
614
|
+
confirmed,
|
|
615
|
+
...(pending.length
|
|
616
|
+
? {
|
|
617
|
+
pending,
|
|
618
|
+
warning: `no delivery receipt within ${deliveryTimeoutMs}ms from: ${pending.join(", ")} — written but may not have reached their panes (stale/wedged pusher). Run doctor.`,
|
|
619
|
+
}
|
|
620
|
+
: {}),
|
|
621
|
+
}
|
|
622
|
+
: {}),
|
|
542
623
|
...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: delivered } } : {}),
|
|
543
624
|
};
|
|
544
625
|
}
|
|
@@ -565,7 +646,7 @@ export async function readMessagesTool(args: {
|
|
|
565
646
|
const file = sourceFile(args.source, args.agentId, args.room);
|
|
566
647
|
const all = await readJsonl<Message | StatusEntry>(file);
|
|
567
648
|
|
|
568
|
-
let
|
|
649
|
+
let entries: (Message | StatusEntry)[] = [];
|
|
569
650
|
let totalNew = 0;
|
|
570
651
|
|
|
571
652
|
// Room reads default to 50 messages to prevent agents flooding themselves
|
|
@@ -576,29 +657,53 @@ export async function readMessagesTool(args: {
|
|
|
576
657
|
if (args.peek) {
|
|
577
658
|
const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
|
|
578
659
|
const startOffset = getOffset(cursor, args.source, args.room);
|
|
579
|
-
|
|
660
|
+
entries = all.slice(startOffset);
|
|
580
661
|
if (args.sinceTs !== undefined) entries = entries.filter((e) => e.ts > args.sinceTs!);
|
|
581
662
|
totalNew = entries.length;
|
|
582
|
-
limited = effectiveLimit ? entries.slice(0, effectiveLimit) : entries;
|
|
583
663
|
} else {
|
|
584
664
|
await updateJson<Cursor>(cursorFile(args.agentId), {}, (current) => {
|
|
585
665
|
const startOffset = getOffset(current, args.source, args.room);
|
|
586
|
-
let
|
|
587
|
-
if (args.sinceTs !== undefined)
|
|
588
|
-
totalNew =
|
|
589
|
-
|
|
590
|
-
|
|
666
|
+
let e = all.slice(startOffset);
|
|
667
|
+
if (args.sinceTs !== undefined) e = e.filter((x) => x.ts > args.sinceTs!);
|
|
668
|
+
totalNew = e.length;
|
|
669
|
+
entries = e;
|
|
670
|
+
// Advance past EVERYTHING we account for here (recent window + any
|
|
671
|
+
// overflow we stash below). The overflow is recoverable via the history
|
|
672
|
+
// hash, so it must not requeue for the next read — that would re-flood.
|
|
673
|
+
if (e.length > 0) setOffset(current, args.source, args.room, startOffset + e.length);
|
|
591
674
|
return current;
|
|
592
675
|
});
|
|
593
676
|
}
|
|
594
677
|
|
|
678
|
+
// CCR overflow handling (room source only). When the backlog exceeds the
|
|
679
|
+
// window, return the RECENT slice raw and replace the older overflow with a
|
|
680
|
+
// compact digest carrying a retrieval hash. The agent expands it on demand
|
|
681
|
+
// via retrieve_room_history. Peek is side-effect-free, so it never stashes —
|
|
682
|
+
// it reports the count and tells the agent to do a real read to get a hash.
|
|
683
|
+
let recent = entries;
|
|
684
|
+
let history: { digest: string; hash?: string; older: number } | undefined;
|
|
685
|
+
if (args.source === "room" && effectiveLimit && entries.length > effectiveLimit) {
|
|
686
|
+
const overflow = entries.slice(0, entries.length - effectiveLimit);
|
|
687
|
+
recent = entries.slice(entries.length - effectiveLimit);
|
|
688
|
+
const room = normalizeRoom(args.room);
|
|
689
|
+
if (args.peek) {
|
|
690
|
+
history = { digest: digestOverflow(overflow, undefined), older: overflow.length };
|
|
691
|
+
} else {
|
|
692
|
+
const hash = await stashHistory(room, args.agentId, overflow);
|
|
693
|
+
history = { digest: digestOverflow(overflow, hash), hash, older: overflow.length };
|
|
694
|
+
}
|
|
695
|
+
} else if (effectiveLimit && entries.length > effectiveLimit) {
|
|
696
|
+
// Non-room sources keep the legacy oldest-first chunking (no stash).
|
|
697
|
+
recent = entries.slice(0, effectiveLimit);
|
|
698
|
+
}
|
|
699
|
+
|
|
595
700
|
// Drop the agent's own posts on shared channels — reading your own broadcast
|
|
596
701
|
// back is never useful and confuses turn-based agents into self-replies.
|
|
597
702
|
// Cursor has already advanced past them, so they won't reappear.
|
|
598
703
|
const visible =
|
|
599
704
|
args.source === "room" || args.source === "status"
|
|
600
|
-
?
|
|
601
|
-
:
|
|
705
|
+
? recent.filter((e) => entryAuthor(e) !== args.agentId)
|
|
706
|
+
: recent;
|
|
602
707
|
|
|
603
708
|
return {
|
|
604
709
|
ok: true,
|
|
@@ -606,6 +711,64 @@ export async function readMessagesTool(args: {
|
|
|
606
711
|
totalNew,
|
|
607
712
|
returned: visible.length,
|
|
608
713
|
room: args.source === "room" ? normalizeRoom(args.room) : undefined,
|
|
714
|
+
...(history ? { history } : {}),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Lossless summary of a stashed backlog slice: surfaces error/failure posts
|
|
719
|
+
// verbatim (the lines that usually matter most in a flood) and collapses the
|
|
720
|
+
// rest to counts. Mirrors headroom's content-aware digest, kept deliberately
|
|
721
|
+
// simple — the full originals are one retrieve_room_history call away.
|
|
722
|
+
function digestOverflow(over: (Message | StatusEntry)[], hash: string | undefined): string {
|
|
723
|
+
const authors = new Set(over.map(entryAuthor).filter(Boolean));
|
|
724
|
+
const errorRe = /\b(error|fatal|fail(ed|ure)?|panic|exception)\b/i;
|
|
725
|
+
const errors = over.filter((m) => errorRe.test(JSON.stringify(m)));
|
|
726
|
+
const first = over[0]?.ts;
|
|
727
|
+
const last = over[over.length - 1]?.ts;
|
|
728
|
+
const span =
|
|
729
|
+
first && last && last > first ? ` over ${Math.round((last - first) / 60000)}m` : "";
|
|
730
|
+
const parts = [
|
|
731
|
+
`[${over.length} earlier message${over.length === 1 ? "" : "s"} compressed`,
|
|
732
|
+
`${authors.size} agent${authors.size === 1 ? "" : "s"}${span}`,
|
|
733
|
+
];
|
|
734
|
+
if (errors.length) parts.push(`${errors.length} error post${errors.length === 1 ? "" : "s"}`);
|
|
735
|
+
const head = parts.join(", ");
|
|
736
|
+
const tail = hash
|
|
737
|
+
? ` hash=${hash}] — call retrieve_room_history(hash="${hash}") to expand`
|
|
738
|
+
: `] — read without peek to get an expandable hash`;
|
|
739
|
+
return head + tail;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ---------- retrieve_room_history ----------
|
|
743
|
+
|
|
744
|
+
export const retrieveRoomHistorySchema = {
|
|
745
|
+
agentId: z.string().min(1),
|
|
746
|
+
hash: z.string().min(1),
|
|
747
|
+
query: z.string().optional(),
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
export async function retrieveRoomHistoryTool(args: {
|
|
751
|
+
agentId: string;
|
|
752
|
+
hash: string;
|
|
753
|
+
query?: string;
|
|
754
|
+
}) {
|
|
755
|
+
const res = await retrieveHistory<Message | StatusEntry>(args.hash, args.agentId, args.query);
|
|
756
|
+
if (!res.ok) {
|
|
757
|
+
const reason =
|
|
758
|
+
res.reason === "expired"
|
|
759
|
+
? "That history entry has expired (30m TTL). Re-read the channel with a higher limit to fetch it again."
|
|
760
|
+
: res.reason === "forbidden"
|
|
761
|
+
? "That history hash was produced for a different agent and cannot be retrieved by you."
|
|
762
|
+
: "No history entry for that hash. It may have expired or never existed.";
|
|
763
|
+
return { ok: false, reason: res.reason, message: reason };
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
ok: true,
|
|
767
|
+
room: res.room,
|
|
768
|
+
hash: args.hash,
|
|
769
|
+
total: res.total,
|
|
770
|
+
returned: res.messages.length,
|
|
771
|
+
messages: res.messages,
|
|
609
772
|
};
|
|
610
773
|
}
|
|
611
774
|
|
|
@@ -744,6 +907,14 @@ export async function pruneTool(args: {
|
|
|
744
907
|
for (const e of Object.values(rooms)) {
|
|
745
908
|
for (const m of e.members ?? []) if (!knownAgents.has(m)) orphanMembers.add(m);
|
|
746
909
|
}
|
|
910
|
+
let receiptsRemoved = 0;
|
|
911
|
+
const orphanReceipts: string[] = [];
|
|
912
|
+
for (const filePath of await listReceiptFiles()) {
|
|
913
|
+
const id = path.basename(filePath).replace(/\.jsonl$/, "");
|
|
914
|
+
const entries = await readJsonl<{ ts: number }>(filePath);
|
|
915
|
+
if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) orphanReceipts.push(id);
|
|
916
|
+
receiptsRemoved += entries.filter((e) => e.ts <= cutoff).length;
|
|
917
|
+
}
|
|
747
918
|
return {
|
|
748
919
|
dryRun: true,
|
|
749
920
|
cutoff,
|
|
@@ -754,6 +925,8 @@ export async function pruneTool(args: {
|
|
|
754
925
|
inboxMessages: inboxRemoved,
|
|
755
926
|
orphanInboxes: orphans,
|
|
756
927
|
orphanMembers: [...orphanMembers],
|
|
928
|
+
receipts: receiptsRemoved,
|
|
929
|
+
orphanReceipts,
|
|
757
930
|
},
|
|
758
931
|
};
|
|
759
932
|
}
|
|
@@ -845,6 +1018,27 @@ export async function pruneTool(args: {
|
|
|
845
1018
|
return current;
|
|
846
1019
|
});
|
|
847
1020
|
|
|
1021
|
+
// Receipts are out-of-band proof logs with no cursor — trim old entries by ts
|
|
1022
|
+
// and delete logs for agents no longer registered. No offset adjustment needed.
|
|
1023
|
+
let receiptsRemoved = 0;
|
|
1024
|
+
const deletedOrphanReceipts: string[] = [];
|
|
1025
|
+
for (const filePath of await listReceiptFiles()) {
|
|
1026
|
+
const id = path.basename(filePath).replace(/\.jsonl$/, "");
|
|
1027
|
+
if (!knownAgents.has(id) && (args.removeOrphanInboxes ?? true)) {
|
|
1028
|
+
const entries = await readJsonl<{ ts: number }>(filePath);
|
|
1029
|
+
receiptsRemoved += entries.length;
|
|
1030
|
+
await deleteFile(filePath);
|
|
1031
|
+
deletedOrphanReceipts.push(id);
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
const r = await rewriteJsonl<{ ts: number }>(filePath, (e) => e.ts > cutoff);
|
|
1035
|
+
receiptsRemoved += r.removed;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Sweep expired reversible-history entries (TTL'd cache; also self-prunes on
|
|
1039
|
+
// every read, so this just catches entries on a server with few reads).
|
|
1040
|
+
await pruneHistory();
|
|
1041
|
+
|
|
848
1042
|
return {
|
|
849
1043
|
dryRun: false,
|
|
850
1044
|
cutoff,
|
|
@@ -855,6 +1049,8 @@ export async function pruneTool(args: {
|
|
|
855
1049
|
inboxMessages: inboxRemoved,
|
|
856
1050
|
orphanInboxes: deletedOrphans,
|
|
857
1051
|
orphanMembers: [...orphanMembers],
|
|
1052
|
+
receipts: receiptsRemoved,
|
|
1053
|
+
orphanReceipts: deletedOrphanReceipts,
|
|
858
1054
|
},
|
|
859
1055
|
cursorsAdjusted,
|
|
860
1056
|
};
|