@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 +22 -11
- package/CHANGELOG.md +15 -1
- package/bin/agentboard.js +108 -3
- package/opencode/plugins/dm-watch.js +46 -2
- package/package.json +1 -1
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.
|
|
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/<name>.json { name, firstSeen, lastSeen, sessionId?, lastDir? }
|
|
79
|
-
dm/<recipient>/<id>.json { id, from, to, body, at }
|
|
79
|
+
dm/<recipient>/<id>.json { id, from, to, body, at, subject?, replyTo?, batch?, rev? }
|
|
80
80
|
delivered/<recipient>/<id>.json fire-once markers { by, sessionID?, at }
|
|
81
81
|
cursors/<agent>.json fast-forward pointer { lastId, at }</code></pre>
|
|
82
|
-
<p>Message ids look like <code>msg-260920-114948-
|
|
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 <command> [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 <you> [--session <id>]
|
|
89
89
|
agentboard agents [--json]
|
|
90
|
-
agentboard send --from <you> --to <peer> --body "..." [--session <id>]
|
|
90
|
+
agentboard send --from <you> --to <peer> --body "..." [--subject "..."] [--reply <msg-id>] [--session <id>]
|
|
91
91
|
agentboard inbox --from <you> [--limit 20] [--after <msg-id>] [--all] [--json]
|
|
92
92
|
agentboard listen --from <you> [--timeout <ms>] [--json]
|
|
93
93
|
agentboard doctor [--harness <list>] [--board <path>]</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).
|
|
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 <id> -> <to> [board <path>]</code>; fan-outs echo <code>sent N messages [board <path>]</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 <list>] [--board <path>]</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><cwd>/.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 <list>] [--board <path>]</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
|
|
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 <path>]</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 <you> --style <s> [--idle-after <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
|
+
# -> sent 3 messages [board C:/proj/.agentboard]: msg-… -> 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 <path>]</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/<you>/</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).
|
|
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 & publish</h2>
|
|
178
188
|
<pre><code>npm i -g @eamonpluto/agentboard # global install
|
|
@@ -181,6 +191,7 @@ agentboard init --harness <name> --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 <name> --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.
|
|
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.
|
|
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
|
|
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
|
|
232
|
+
message: "DM push failed for " + agent + " (" + msg.id + "), released for retry: " + detail,
|
|
189
233
|
},
|
|
190
234
|
});
|
|
191
235
|
} catch {}
|
package/package.json
CHANGED