@eamonpluto/agentboard 2.4.0 → 2.4.1

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/AGENTBOARD.html CHANGED
@@ -54,7 +54,7 @@
54
54
  <main>
55
55
 
56
56
  <h1>agentboard</h1>
57
- <p class="sub">A zero-dependency local DM bus for AI coding agents — v2.3.0 · package <code>@eamonpluto/agentboard</code></p>
57
+ <p class="sub">A zero-dependency local DM bus for AI coding agents — v2.4.0 · package <code>@eamonpluto/agentboard</code></p>
58
58
 
59
59
  <h2 id="what">1 · What it is</h2>
60
60
  <p>agentboard lets multiple AI coding agents working on the same machine message each other directly. No server, no Redis, no internet, no accounts: the "network" is a directory of small JSON files (<code>.agentboard/</code>), and the "protocol" is one primitive — <strong>send a direct message to another agent</strong>.</p>
@@ -76,10 +76,10 @@
76
76
  <pre><code>.agentboard/
77
77
  board.json { name, version: 2, createdAt, harnesses: [...] }
78
78
  agents/&lt;name&gt;.json { name, firstSeen, lastSeen, sessionId?, lastDir? }
79
- dm/&lt;recipient&gt;/&lt;id&gt;.json { id, from, to, body, at }
79
+ dm/&lt;recipient&gt;/&lt;id&gt;.json { id, from, to, body, at, subject?, replyTo?, batch?, rev? }
80
80
  delivered/&lt;recipient&gt;/&lt;id&gt;.json fire-once markers { by, sessionID?, at }
81
81
  cursors/&lt;agent&gt;.json fast-forward pointer { lastId, at }</code></pre>
82
- <p>Message ids look like <code>msg-260920-114948-8c2f86</code> (UTC timestamp + random suffix), ordered by <code>at</code> then <code>id</code>. Writes are atomic (write-temp-then-rename); delivery claims are atomic exclusive-creates, so concurrent agents and restarts never double-deliver.</p>
82
+ <p>Message ids look like <code>msg-260920-114948-8c2f8641</code> (UTC timestamp + random suffix), ordered by <code>at</code> then <code>id</code>. Fan-outs share a <code>batch</code> id (one brief, one copy per recipient); <code>replyTo</code> threads answers; <code>rev</code> is the sender's git short-HEAD so recipients spot stale <code>file:line</code> numbers. Writes are atomic (pid-tagged write-temp-then-rename, one retry on Windows AV holds); delivery claims are atomic exclusive-creates, so concurrent agents and restarts never double-deliver.</p>
83
83
 
84
84
  <h2 id="cli">4 · CLI reference</h2>
85
85
  <p>Form: <code>agentboard &lt;command&gt; [flags]</code> — command first, flags after. Node 18+.</p>
@@ -87,13 +87,14 @@
87
87
  [--harness opencode,claude,codex,antigravity,grok,generic]
88
88
  agentboard register --from &lt;you&gt; [--session &lt;id&gt;]
89
89
  agentboard agents [--json]
90
- agentboard send --from &lt;you&gt; --to &lt;peer&gt; --body "..." [--session &lt;id&gt;]
90
+ agentboard send --from &lt;you&gt; --to &lt;peer&gt; --body "..." [--subject "..."] [--reply &lt;msg-id&gt;] [--session &lt;id&gt;]
91
91
  agentboard inbox --from &lt;you&gt; [--limit 20] [--after &lt;msg-id&gt;] [--all] [--json]
92
92
  agentboard listen --from &lt;you&gt; [--timeout &lt;ms&gt;] [--json]
93
93
  agentboard doctor [--harness &lt;list&gt;] [--board &lt;path&gt;]</code></pre>
94
94
  <ul>
95
- <li><strong>send</strong> has no cooldown and no types — <code>from</code>, <code>to</code>, <code>body</code> (max 8000 chars). It echoes <code>sent &lt;id&gt; -&gt; &lt;to&gt; [board &lt;path&gt;]</code> so you always see which board you hit. DMs to never-registered agents wait in <code>inbox</code> until they register.</li>
96
- <li><strong>inbox</strong> has no mark-read side effects — page with <code>--after</code>; <code>--all</code> dumps board-wide (debugging).</li>
95
+ <li><strong>send</strong> has no cooldown and no types — <code>from</code>, <code>to</code>, <code>body</code> (max 8000 chars), plus optional <code>--subject</code> (mission line, 120 chars) and <code>--reply</code> (message id you are answering). <code>--to</code> takes a comma list for broadcast (<code>--to alice,bob,carol</code>, max 20, deduped) — one DM each, unique id, shared <code>batch</code> id. Single sends echo <code>sent &lt;id&gt; -&gt; &lt;to&gt; [board &lt;path&gt;]</code>; fan-outs echo <code>sent N messages [board &lt;path&gt;]</code>. Every send stamps the sender's git rev (omitted outside checkouts). DMs to never-registered agents wait in <code>inbox</code> until they register.</li>
96
+ <li><strong>inbox</strong> has no mark-read side effects — page with <code>--after</code>; <code>--all</code> dumps board-wide (debugging). Text output shows <code>subj:</code> / <code>re:</code> / <code>rev</code> / <code>batch</code> lines; empty inboxes name their board so a wrong-board empty room is distinguishable from no mail.</li>
97
+ <li><strong>agents / inbox / listen never create a board</strong> — on a path with no <code>board.json</code> they fail loudly with the resolved path instead of showing an empty room. Only <code>send</code>, <code>register</code>, and <code>init</code> plant boards.</li>
97
98
  <li><strong>listen</strong> prints the backlog, then blocks and prints new DMs as they arrive (for harnesses without push).</li>
98
99
  <li>Removed v1 commands (<code>task claim messages stats …</code>) fail with a pointer to <code>send</code>.</li>
99
100
  </ul>
@@ -103,7 +104,7 @@ agentboard doctor [--harness &lt;list&gt;] [--board &lt;path&gt;]</code></pre>
103
104
  <ol>
104
105
  <li>explicit per-call path (<code>--board</code>, or <code>board</code> arg on tools)</li>
105
106
  <li><code>AGENTBOARD_DIR</code> environment variable</li>
106
- <li>walk-up: nearest ancestor containing <code>.agentboard</code> (so subdirectory sessions converge on the project board)</li>
107
+ <li>walk-up: nearest ancestor containing <code>.agentboard</code> (so subdirectory sessions converge on the project board; the opencode tool tries worktree, then directory, then cwd)</li>
107
108
  <li>fallback: <code>&lt;cwd&gt;/.agentboard</code> (created on write)</li>
108
109
  </ol>
109
110
  <div class="warn"><strong>Drive-root guard.</strong> Writers refuse to auto-create a board at a filesystem root (e.g. <code>C:\.agentboard</code>) and fail loudly instead — that pattern means cwd resolution failed (detached harness worktree). Pass an explicit path or set <code>AGENTBOARD_DIR</code>. (<code>init</code> is exempt: it always plants where you stand.)</div>
@@ -122,7 +123,7 @@ agentboard doctor [--harness &lt;list&gt;] [--board &lt;path&gt;]</code></pre>
122
123
  </table>
123
124
 
124
125
  <h2 id="mcp">7 · MCP server</h2>
125
- <p><code>bin/agentboard-mcp.js</code> — stdio, newline-delimited JSON-RPC, no dependencies. Methods: <code>initialize</code> (version negotiation, falls back to <code>2024-11-05</code>), <code>tools/list</code>, <code>tools/call</code>, <code>ping</code>. Tools: <code>dm_send(from,to,body)</code>, <code>dm_inbox(agent,limit?,after?)</code>, <code>dm_agents()</code>, <code>dm_register(agent,session?)</code> — each accepting an optional <code>board</code> absolute path. Errors return <code>isError</code> results, never crashes.</p>
126
+ <p><code>bin/agentboard-mcp.js</code> — stdio, newline-delimited JSON-RPC, no dependencies. Methods: <code>initialize</code> (version negotiation, falls back to <code>2024-11-05</code>), <code>tools/list</code>, <code>tools/call</code>, <code>ping</code>. Tools: <code>dm_send(from,to,body,subject?,replyTo?)</code> (<code>to</code> accepts a comma-list broadcast), <code>dm_inbox(agent,limit?,after?)</code>, <code>dm_agents()</code>, <code>dm_register(agent,session?)</code> — each accepting an optional <code>board</code> absolute path. <code>dm_inbox</code>/<code>dm_agents</code> never create a board (loud <code>no board</code> error instead); every result echoes <code>[board &lt;path&gt;]</code>. Errors return <code>isError</code> results, never crashes.</p>
126
127
 
127
128
  <h2 id="hooks">8 · Hook helper</h2>
128
129
  <p><code>bin/agentboard-hook.js</code> bridges hook-capable harnesses:</p>
@@ -146,7 +147,7 @@ agentboard-hook poll --from &lt;you&gt; --style &lt;s&gt; [--idle-after &lt;sec&
146
147
  <p><code>init</code> creates the board, manages one <code>agentboard:start/end</code> block in <code>AGENTS.md</code> (removing legacy v1 text), installs harness wiring, records the choice in <code>board.json</code>, and prints follow-ups. Explicit <code>--harness</code> (repeatable / comma-separated) wins; otherwise init applies the <strong>union of detected markers</strong> (<code>.opencode .claude .codex .agents .grok</code>); with no markers it keeps the legacy opencode default. JSON merges are additive and idempotent — your own hooks and servers are never touched. <code>--portable</code> writes PATH-based MCP entries for global installs; <code>--force</code> overwrites installed wiring.</p>
147
148
 
148
149
  <h2 id="doctor">11 · doctor</h2>
149
- <p><code>agentboard doctor</code> validates node ≥18, board v2, the AGENTS.md block, and per-harness files (hook references, MCP server entries), printing <code>ok/FAIL/info</code> lines and exiting 1 when broken. Steps it can't verify (Codex/grok MCP registration, trust grants) print as <code>info</code> reminders.</p>
150
+ <p><code>agentboard doctor</code> validates node ≥18, board v2, the AGENTS.md block, and per-harness files (hook references, MCP server entries), printing <code>ok/FAIL/info</code> lines and exiting 1 when broken. It also prints the resolved board, <code>AGENTBOARD_DIR</code> state, cwd, and git rev, so split-board diagnosis is one command. Steps it can't verify (Codex/grok MCP registration, trust grants) print as <code>info</code> reminders.</p>
150
151
 
151
152
  <h2 id="workflow">12 · Workflows</h2>
152
153
  <h3>Two agents, one question (verified live)</h3>
@@ -161,6 +162,15 @@ question: what is my code word? reply with the word
161
162
  (Reply with dm-send if needed, or continue current work if unrelated.)
162
163
 
163
164
  # bob answers the same way; alice gets it pushed. Word matched both ways.</code></pre>
165
+ <h3>Fan-out: "assign N agents" (the DM <em>is</em> the task)</h3>
166
+ <pre><code># one call — one copy each, shared batch id, no task object
167
+ agentboard send --from ui-lead --to alice,bob,carol --subject "brief: borderless cards" \
168
+ --body "Audit your scope, drop decorative borders, DM me a summary."
169
+ # -&gt; sent 3 messages [board C:/proj/.agentboard]: msg-… -&gt; alice, …
170
+
171
+ # each agent owns its scope, decides itself, threads its summary back:
172
+ agentboard send --from alice --to ui-lead --reply msg-260921-055320-a854de --body "cards done: …"</code></pre>
173
+ <p>Conventions: one DM = one brief (mission + scope + definition of done); re-read cited files before flagging (every DM stamps the sender's git rev — if your checkout is newer, the <code>file:line</code> numbers may be stale); compare <code>[board &lt;path&gt;]</code> echoes when an inbox looks unexpectedly empty.</p>
164
174
  <h3>Split board? (the one failure mode seen live)</h3>
165
175
  <pre><code># every send echoes its board — compare the two agents' outputs;
166
176
  # if they differ, converge them:
@@ -172,7 +182,7 @@ agentboard doctor # confirm all-ok</code></pre>
172
182
  <div class="warn"><strong>The board is unauthenticated by design.</strong> Any process on the machine can write <code>dm/&lt;you&gt;/</code> or send as your <code>--from</code> name — honor system only. Don't share one board across trust levels (sandboxed untrusted agents + privileged agents); use separate <code>AGENTBOARD_DIR</code> boards per trust zone. Never post secrets — post references. Hook scripts and MCP servers run with your user privileges: review project hooks before trusting them (<code>/hooks</code>, <code>/hooks-trust</code>), which is also what each harness itself requires.</div>
173
183
 
174
184
  <h2 id="tests">14 · Tests</h2>
175
- <p><code>npm test</code> runs <code>test/agentboard.smoke.mjs</code> (CLI: layout, isolation, ordering, cursors, validation, v1-removal hints, live <code>listen</code>, init, doctor, walk-up, drive-root guard) and <code>test/agentboard.harness.mjs</code> (MCP handshake/tools/overrides, hook styles/caps/idle/markers, plugin↔hook cross-delivery, all adapters incl. merge preservation, idempotency, auto-detect, portable entries). 88 checks, all green at v2.3.0.</p>
185
+ <p><code>npm test</code> runs <code>test/agentboard.smoke.mjs</code> (CLI: layout, isolation, ordering, cursors, validation, v1-removal hints, broadcast fan-out + subject/reply threading, read-no-create guards, live <code>listen</code>, init, doctor, walk-up, drive-root guard) and <code>test/agentboard.harness.mjs</code> (MCP handshake/tools/overrides/broadcast/no-board, tool broadcast + overrides, hook styles/caps/idle/markers/subject lines, plugin↔hook cross-delivery, all adapters incl. merge preservation, idempotency, auto-detect, portable entries). 113 checks, all green at v2.4.0. <code>sync-embeds.mjs</code> (repo root, dev-only) re-embeds the opencode tool + plugin into <code>bin/agentboard.js</code> for global installs — run it after editing either file.</p>
176
186
 
177
187
  <h2 id="publish">15 · Install &amp; publish</h2>
178
188
  <pre><code>npm i -g @eamonpluto/agentboard # global install
@@ -181,6 +191,7 @@ agentboard init --harness &lt;name&gt; --portable # portable wiring
181
191
 
182
192
  <h2 id="changes">16 · Changelog</h2>
183
193
  <ul>
194
+ <li><strong>2.4.0</strong> — broadcast send (<code>--to a,b,c</code>, shared batch id); <code>--subject</code>/<code>--reply</code> threading + git-rev staleness stamps; read commands never plant boards; <code>[board]</code> echo everywhere; parallel-send hardening; fan-out docs.</li>
184
195
  <li><strong>2.3.0</strong> — walk-up board resolution everywhere; <code>[board]</code> echo on sends; <code>board</code> param on dm-send + MCP tools; drive-root creation guard; troubleshooting docs.</li>
185
196
  <li><strong>2.2.0</strong> — batched hook delivery (5/poll); unified hook↔plugin tracking; <code>doctor</code>; <code>--idle-after</code>; <code>--portable</code>; npm <code>files</code> allowlist. Published as <code>@eamonpluto/agentboard</code>.</li>
186
197
  <li><strong>2.1.0</strong> — MCP server, hook helper, harness adapters, <code>init --harness</code> with auto-detect.</li>
@@ -188,7 +199,7 @@ agentboard init --harness &lt;name&gt; --portable # portable wiring
188
199
  <li><strong>1.1.0</strong> — legacy task-board model.</li>
189
200
  </ul>
190
201
 
191
- <footer>agentboard manual · generated for the v2.3.0 tree · zero dependencies, node ≥18 · MIT</footer>
202
+ <footer>agentboard manual · generated for the v2.4.0 tree · zero dependencies, node ≥18 · MIT</footer>
192
203
 
193
204
  </main>
194
205
  </div>
package/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## 2.4.0 (unpublished)
3
+ ## 2.4.1 (unpublished)
4
+
5
+ - Opencode watcher is now at-least-once: a failed `promptAsync` push (e.g.
6
+ into a stale session failing with `encrypted_content was not issued to
7
+ this caller`) releases the `delivered/<agent>/<msg>.json` marker, leaves
8
+ `cursors/<agent>.json` alone, and invalidates the stale
9
+ `agents/<agent>.json` sessionId so mail waits for pull until the live
10
+ session re-registers. The cursor advances only on success, so failed
11
+ pushes are retried instead of black-holed.
12
+ - New `redeliver --from <you> (--id <msg-id> | --all)` recovers mail a dead
13
+ watcher already consumed: clears delivered markers and rewinds the cursor
14
+ so the next poll/push treats the message as fresh (the DM itself is never
15
+ touched — `inbox` always shows full history).
16
+
17
+ ## 2.4.0 (published 2026-09-21)
4
18
 
5
19
  - Broadcast send: `--to alice,bob,carol` (CLI) / comma-list `to`
6
20
  (`dm-send` tool, `dm_send` MCP) fans one brief out to up to 20 agents —
package/bin/agentboard.js CHANGED
@@ -192,7 +192,7 @@ function getFlag(args, flag) {
192
192
 
193
193
  // Positional args with flag values removed (so `send --from alice --to bob`
194
194
  // with no body doesn't mistake "alice bob" for a message).
195
- const VALUE_FLAGS = new Set(["--from", "--to", "--body", "--subject", "--reply", "--session", "--board", "--limit", "--after", "--timeout"]);
195
+ const VALUE_FLAGS = new Set(["--from", "--to", "--body", "--subject", "--reply", "--replyTo", "--session", "--board", "--limit", "--after", "--timeout", "--id"]);
196
196
 
197
197
  // Comma-separated recipients: `--to alice,bob,carol` fans out one DM per
198
198
  // recipient (same body/subject, unique id each). Keeps the DM-only model
@@ -503,6 +503,13 @@ const OPENCODE_PLUGIN_DM_WATCH = `// .opencode/plugins/dm-watch.js — inject DM
503
503
  // mixing harnesses never get a message twice.
504
504
  // Polls every 1s; that poll is the source of truth (no fs.watch dependency).
505
505
  //
506
+ // Delivery is at-least-once: the claim wins the race between watcher
507
+ // instances, but the marker is released (and the cursor left alone) when
508
+ // promptAsync throws — e.g. pushing into a stale session from yesterday
509
+ // fails with \`encrypted_content was not issued to this caller\`. Stale
510
+ // session mappings are then invalidated so mail waits for pull until the
511
+ // live session re-registers, instead of being black-holed as delivered.
512
+ //
506
513
  // Agents with no known session are skipped — their mail waits in the inbox
507
514
  // for pull (\`inbox --from <you>\`), so one idle session never steals another
508
515
  // agent's mail.
@@ -599,6 +606,40 @@ export const DmWatchPlugin = async ({ client, directory }) => {
599
606
  }
600
607
  }
601
608
 
609
+ // Roll back a claim won above when the push itself fails: remove the
610
+ // on-disk marker and the in-memory entry so the next poll retries.
611
+ // The cursor is intentionally left alone here — it only advances on
612
+ // success, so a failed push never fast-forwards past undelivered mail.
613
+ function releaseClaim(agent, id) {
614
+ processed.delete(agent + "/" + id);
615
+ try {
616
+ fs.rmSync(deliveredMarker(agent, id), { force: true });
617
+ } catch {}
618
+ }
619
+
620
+ // promptAsync into a dead session throws provider errors like
621
+ // "[invalid_request_error] reasoning \`encrypted_content\` was not issued
622
+ // to this caller". Those mean the routing entry is stale, not the
623
+ // message — drop the mapping so mail waits for pull (\`inbox\`) until the
624
+ // live session re-registers via \`register --session\` or \`dm-send\`.
625
+ function isStaleSessionError(e) {
626
+ const s = String((e && e.message ? e.message : e) || "");
627
+ return /encrypted_content|invalid_request_error|unknown session|session not found|no such session|not issued to this caller/i.test(s);
628
+ }
629
+
630
+ function invalidateSession(agent, sessionID) {
631
+ agentToSession.delete(agent);
632
+ try {
633
+ const p = path.join(agentsDir, agent + ".json");
634
+ const doc = readJsonSafe(p);
635
+ if (doc && doc.sessionId === sessionID) {
636
+ delete doc.sessionId;
637
+ doc.lastSeen = new Date().toISOString();
638
+ fs.writeFileSync(p, JSON.stringify(doc, null, 2) + "\\n");
639
+ }
640
+ } catch {}
641
+ }
642
+
602
643
  // Cursor file shared with agentboard-hook: hook delivery moves it, and we
603
644
  // honor it (plus our markers) so mixed-harness agents never get doubles.
604
645
  // We also advance it on our own deliveries.
@@ -662,7 +703,6 @@ export const DmWatchPlugin = async ({ client, directory }) => {
662
703
 
663
704
  async function deliver(agent, sessionID, msg) {
664
705
  if (!claim(agent, msg.id, sessionID)) return;
665
- advanceCursor(agent, msg.id);
666
706
  const text = formatDm(msg);
667
707
  try {
668
708
  if (client.session && typeof client.session.promptAsync === "function") {
@@ -670,13 +710,17 @@ export const DmWatchPlugin = async ({ client, directory }) => {
670
710
  } else if (client.session && typeof client.session.prompt === "function") {
671
711
  await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
672
712
  }
713
+ advanceCursor(agent, msg.id);
673
714
  } catch (e) {
715
+ const detail = e && e.message ? e.message : String(e);
716
+ releaseClaim(agent, msg.id);
717
+ if (isStaleSessionError(e)) invalidateSession(agent, sessionID);
674
718
  try {
675
719
  await client.app.log({
676
720
  body: {
677
721
  service: "dm-watch",
678
722
  level: "warn",
679
- message: "DM wake failed for " + agent + ": " + (e && e.message ? e.message : String(e)),
723
+ message: "DM push failed for " + agent + " (" + msg.id + "), released for retry: " + detail,
680
724
  },
681
725
  });
682
726
  } catch {}
@@ -1417,6 +1461,62 @@ async function cmdListen(args) {
1417
1461
  finish();
1418
1462
  }
1419
1463
 
1464
+ // ---------------------------------------------------------------------------
1465
+ // redeliver: recover mail a dead watcher consumed (claimed + cursor moved,
1466
+ // push failed). Clears delivered/<agent>/<id>.json markers and rewinds
1467
+ // cursors/<agent>.json so the next poll/push treats the message as fresh.
1468
+ // The DM itself is never touched — inbox always shows the full history.
1469
+ // ---------------------------------------------------------------------------
1470
+
1471
+ function cmdRedeliver(args) {
1472
+ const root = boardDir(args);
1473
+ refuseDriveRootBoard(root, args);
1474
+ const d = requireBoard(root);
1475
+ const agent = resolveAgent(args, "agent");
1476
+ const id = getFlag(args, "--id");
1477
+ const all = args.includes("--all");
1478
+ if (!id && !all) fail('missing --id <msg-id> (or --all to reset every delivery marker)');
1479
+ if (id && all) fail('pass --id <msg-id> or --all, not both');
1480
+ const order = readDMs(d, agent).map((m) => m.id);
1481
+ let ids;
1482
+ if (all) {
1483
+ ids = order.slice();
1484
+ if (ids.length === 0) fail(`no messages for ${agent}`);
1485
+ } else {
1486
+ if (!order.includes(id)) fail(`unknown message "${id}" for ${agent} (check inbox --from ${agent})`);
1487
+ ids = [id];
1488
+ }
1489
+ for (const mid of ids) {
1490
+ try {
1491
+ fs.rmSync(path.join(d.delivered, agent, `${mid}.json`), { force: true });
1492
+ } catch {}
1493
+ }
1494
+ // Rewind the cursor to the message before the earliest redelivered one so
1495
+ // hook polls and the opencode watcher see it as fresh again. If the
1496
+ // earliest redelivered message is the first in the log (or --all), drop
1497
+ // the cursor entirely.
1498
+ const cursorPath = path.join(d.root, "cursors", `${agent}.json`);
1499
+ if (all) {
1500
+ try {
1501
+ fs.rmSync(cursorPath, { force: true });
1502
+ } catch {}
1503
+ } else {
1504
+ const earliest = order.indexOf(ids[0]);
1505
+ if (earliest <= 0) {
1506
+ try {
1507
+ fs.rmSync(cursorPath, { force: true });
1508
+ } catch {}
1509
+ } else {
1510
+ writeJson(cursorPath, { lastId: order[earliest - 1], at: new Date().toISOString() });
1511
+ }
1512
+ }
1513
+ if (ids.length === 1) {
1514
+ console.log(`redelivered ${ids[0]} for ${agent} [board ${d.root}]`);
1515
+ } else {
1516
+ console.log(`redelivered ${ids.length} messages for ${agent} [board ${d.root}]`);
1517
+ }
1518
+ }
1519
+
1420
1520
  // ---------------------------------------------------------------------------
1421
1521
  // doctor: validate board + harness wiring
1422
1522
  // ---------------------------------------------------------------------------
@@ -1564,6 +1664,10 @@ Messaging (primitive — just a tool call, whenever you want):
1564
1664
  agentboard listen --from <you> [--timeout <ms>] [--json]
1565
1665
  (prints backlog, then blocks and prints new DMs as they arrive;
1566
1666
  opencode plugin injects into context automatically instead of polling)
1667
+ agentboard redeliver --from <you> (--id <msg-id> | --all)
1668
+ (recover mail a dead watcher consumed: clears delivered markers and
1669
+ rewinds the cursor so the next poll/push treats it as fresh;
1670
+ use after re-registering with the live session)
1567
1671
 
1568
1672
  agentboard doctor [--harness <list>] [--board <path>]
1569
1673
  (validate board + harness wiring; exit 1 with FAIL lines when broken)
@@ -1586,6 +1690,7 @@ async function main() {
1586
1690
  case "send": return cmdSend(rest);
1587
1691
  case "inbox": return cmdInbox(rest);
1588
1692
  case "listen": return await cmdListen(rest);
1693
+ case "redeliver": return cmdRedeliver(rest);
1589
1694
  case "doctor": return cmdDoctor(rest);
1590
1695
  case undefined:
1591
1696
  case "-h":
@@ -12,6 +12,13 @@
12
12
  // mixing harnesses never get a message twice.
13
13
  // Polls every 1s; that poll is the source of truth (no fs.watch dependency).
14
14
  //
15
+ // Delivery is at-least-once: the claim wins the race between watcher
16
+ // instances, but the marker is released (and the cursor left alone) when
17
+ // promptAsync throws — e.g. pushing into a stale session from yesterday
18
+ // fails with `encrypted_content was not issued to this caller`. Stale
19
+ // session mappings are then invalidated so mail waits for pull until the
20
+ // live session re-registers, instead of being black-holed as delivered.
21
+ //
15
22
  // Agents with no known session are skipped — their mail waits in the inbox
16
23
  // for pull (`inbox --from <you>`), so one idle session never steals another
17
24
  // agent's mail.
@@ -108,6 +115,40 @@ export const DmWatchPlugin = async ({ client, directory }) => {
108
115
  }
109
116
  }
110
117
 
118
+ // Roll back a claim won above when the push itself fails: remove the
119
+ // on-disk marker and the in-memory entry so the next poll retries.
120
+ // The cursor is intentionally left alone here — it only advances on
121
+ // success, so a failed push never fast-forwards past undelivered mail.
122
+ function releaseClaim(agent, id) {
123
+ processed.delete(agent + "/" + id);
124
+ try {
125
+ fs.rmSync(deliveredMarker(agent, id), { force: true });
126
+ } catch {}
127
+ }
128
+
129
+ // promptAsync into a dead session throws provider errors like
130
+ // "[invalid_request_error] reasoning `encrypted_content` was not issued
131
+ // to this caller". Those mean the routing entry is stale, not the
132
+ // message — drop the mapping so mail waits for pull (`inbox`) until the
133
+ // live session re-registers via `register --session` or `dm-send`.
134
+ function isStaleSessionError(e) {
135
+ const s = String((e && e.message ? e.message : e) || "");
136
+ return /encrypted_content|invalid_request_error|unknown session|session not found|no such session|not issued to this caller/i.test(s);
137
+ }
138
+
139
+ function invalidateSession(agent, sessionID) {
140
+ agentToSession.delete(agent);
141
+ try {
142
+ const p = path.join(agentsDir, agent + ".json");
143
+ const doc = readJsonSafe(p);
144
+ if (doc && doc.sessionId === sessionID) {
145
+ delete doc.sessionId;
146
+ doc.lastSeen = new Date().toISOString();
147
+ fs.writeFileSync(p, JSON.stringify(doc, null, 2) + "\n");
148
+ }
149
+ } catch {}
150
+ }
151
+
111
152
  // Cursor file shared with agentboard-hook: hook delivery moves it, and we
112
153
  // honor it (plus our markers) so mixed-harness agents never get doubles.
113
154
  // We also advance it on our own deliveries.
@@ -171,7 +212,6 @@ export const DmWatchPlugin = async ({ client, directory }) => {
171
212
 
172
213
  async function deliver(agent, sessionID, msg) {
173
214
  if (!claim(agent, msg.id, sessionID)) return;
174
- advanceCursor(agent, msg.id);
175
215
  const text = formatDm(msg);
176
216
  try {
177
217
  if (client.session && typeof client.session.promptAsync === "function") {
@@ -179,13 +219,17 @@ export const DmWatchPlugin = async ({ client, directory }) => {
179
219
  } else if (client.session && typeof client.session.prompt === "function") {
180
220
  await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] } });
181
221
  }
222
+ advanceCursor(agent, msg.id);
182
223
  } catch (e) {
224
+ const detail = e && e.message ? e.message : String(e);
225
+ releaseClaim(agent, msg.id);
226
+ if (isStaleSessionError(e)) invalidateSession(agent, sessionID);
183
227
  try {
184
228
  await client.app.log({
185
229
  body: {
186
230
  service: "dm-watch",
187
231
  level: "warn",
188
- message: "DM wake failed for " + agent + ": " + (e && e.message ? e.message : String(e)),
232
+ message: "DM push failed for " + agent + " (" + msg.id + "), released for retry: " + detail,
189
233
  },
190
234
  });
191
235
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eamonpluto/agentboard",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Zero-dependency local DM bus for AI coding agents: message another agent, inserted into context, just a tool call.",
5
5
  "type": "module",
6
6
  "bin": {