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/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 limited: (Message | StatusEntry)[] = [];
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
- let entries = all.slice(startOffset);
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 entries = all.slice(startOffset);
587
- if (args.sinceTs !== undefined) entries = entries.filter((e) => e.ts > args.sinceTs!);
588
- totalNew = entries.length;
589
- limited = effectiveLimit ? entries.slice(0, effectiveLimit) : entries;
590
- if (limited.length > 0) setOffset(current, args.source, args.room, startOffset + limited.length);
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
- ? limited.filter((e) => entryAuthor(e) !== args.agentId)
601
- : limited;
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
  };