herdr-plugin-amq 0.1.2
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/LICENSE +21 -0
- package/README.md +263 -0
- package/bin/herdr-amq.mjs +74 -0
- package/herdr-plugin.toml +57 -0
- package/package.json +50 -0
- package/skills/herdr-amq/SKILL.md +69 -0
- package/src/actions.mjs +573 -0
- package/src/blobs.mjs +348 -0
- package/src/board.mjs +760 -0
- package/src/bridge.mjs +373 -0
- package/src/briefs.mjs +201 -0
- package/src/config.mjs +146 -0
- package/src/herdr.mjs +215 -0
- package/src/index.mjs +4 -0
- package/src/markdown.mjs +167 -0
- package/src/panes.mjs +68 -0
- package/src/protocol.mjs +347 -0
- package/src/server.mjs +773 -0
- package/src/store.mjs +1063 -0
- package/src/web/app.js +3066 -0
- package/src/web/index.html +727 -0
- package/src/web/style.css +3842 -0
- package/src/worktrees.mjs +217 -0
package/src/protocol.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { storeBlob, ingestAttachment } from "./blobs.mjs";
|
|
5
|
+
|
|
6
|
+
// ─── Constants & Allowed Kinds ───────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export const VALID_KINDS = new Set([
|
|
9
|
+
"brainstorm",
|
|
10
|
+
"review_request",
|
|
11
|
+
"review_response",
|
|
12
|
+
"question",
|
|
13
|
+
"answer",
|
|
14
|
+
"decision",
|
|
15
|
+
"status",
|
|
16
|
+
"todo",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export function normalizeKind(kind) {
|
|
20
|
+
if (!kind) return null;
|
|
21
|
+
const k = String(kind).trim().toLowerCase();
|
|
22
|
+
if (VALID_KINDS.has(k)) return k;
|
|
23
|
+
if (k === "task") return "todo";
|
|
24
|
+
if (k === "alert") return "status";
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── AMQ / RFC 5322 Identifier Generation ────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate a canonical AMQ message ID.
|
|
32
|
+
* Format: <ISO8601-compact>_pid<pid>_<randomHex8>
|
|
33
|
+
* Example: 2026-09-23T10-25-30.123Z_pid12345_a1b2c3d4
|
|
34
|
+
*/
|
|
35
|
+
export function generateMessageId(date = new Date(), pid = process.pid) {
|
|
36
|
+
const iso = date.toISOString().replace(/[:.]/g, "-");
|
|
37
|
+
const rand = crypto.randomBytes(4).toString("hex");
|
|
38
|
+
return `${iso}_pid${pid}_${rand}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Determine a canonical p2p or group thread ID.
|
|
43
|
+
* For 2 participants, sorts lexicographically: p2p/<agentA>__<agentB>
|
|
44
|
+
*/
|
|
45
|
+
export function computeCanonicalThread(from, toList) {
|
|
46
|
+
const recipients = Array.isArray(toList) ? toList : [toList];
|
|
47
|
+
const all = [...new Set([from, ...recipients].filter(Boolean))];
|
|
48
|
+
if (all.length === 2) {
|
|
49
|
+
all.sort();
|
|
50
|
+
return `p2p/${all[0]}__${all[1]}`;
|
|
51
|
+
}
|
|
52
|
+
return `group/${all.sort().join("__")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Serialization & Deserialization ─────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Serialize message metadata and body into AMQ RFC 5322 JSON frontmatter format.
|
|
59
|
+
*/
|
|
60
|
+
export function serializeMessage({
|
|
61
|
+
id,
|
|
62
|
+
from,
|
|
63
|
+
to,
|
|
64
|
+
subject = "",
|
|
65
|
+
body = "",
|
|
66
|
+
thread = null,
|
|
67
|
+
refs = [],
|
|
68
|
+
priority = "normal",
|
|
69
|
+
kind = null,
|
|
70
|
+
labels = [],
|
|
71
|
+
attachments = [],
|
|
72
|
+
context = null,
|
|
73
|
+
created = new Date().toISOString(),
|
|
74
|
+
}) {
|
|
75
|
+
const recipients = Array.isArray(to) ? to : [to];
|
|
76
|
+
const safeThread = thread || computeCanonicalThread(from, recipients);
|
|
77
|
+
const safeKind = normalizeKind(kind);
|
|
78
|
+
|
|
79
|
+
const header = {
|
|
80
|
+
schema: 1,
|
|
81
|
+
id,
|
|
82
|
+
from,
|
|
83
|
+
to: recipients,
|
|
84
|
+
thread: safeThread,
|
|
85
|
+
subject: subject || "(no subject)",
|
|
86
|
+
created,
|
|
87
|
+
priority: priority || "normal",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (refs && refs.length) header.refs = refs;
|
|
91
|
+
if (safeKind) header.kind = safeKind;
|
|
92
|
+
if (labels && labels.length) header.labels = labels;
|
|
93
|
+
if (attachments && attachments.length) header.attachments = attachments;
|
|
94
|
+
if (context && typeof context === "object") header.context = context;
|
|
95
|
+
|
|
96
|
+
return `---json\n${JSON.stringify(header, null, 2)}\n---\n${body || ""}\n`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parse an AMQ message string into frontmatter header and body.
|
|
101
|
+
*/
|
|
102
|
+
export function parseMessage(content = "") {
|
|
103
|
+
const jsonMatch = content.match(/^---json\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
104
|
+
if (jsonMatch) {
|
|
105
|
+
try {
|
|
106
|
+
const header = JSON.parse(jsonMatch[1]);
|
|
107
|
+
return { header, body: jsonMatch[2] };
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const yamlMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
112
|
+
if (yamlMatch) {
|
|
113
|
+
// Basic fallback parsing for YAML frontmatter
|
|
114
|
+
const header = {};
|
|
115
|
+
const lines = yamlMatch[1].split(/\r?\n/);
|
|
116
|
+
for (const l of lines) {
|
|
117
|
+
const kv = l.match(/^([\w-]+)\s*:\s*(.*)$/);
|
|
118
|
+
if (kv) {
|
|
119
|
+
const key = kv[1];
|
|
120
|
+
let val = kv[2].trim();
|
|
121
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
122
|
+
val = val.slice(1, -1);
|
|
123
|
+
}
|
|
124
|
+
header[key] = val;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { header, body: yamlMatch[2] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { header: {}, body: content };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Native Maildir Mailbox Operations ────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Initialize maildir directory structure for an agent:
|
|
137
|
+
* - inbox/tmp
|
|
138
|
+
* - inbox/new
|
|
139
|
+
* - inbox/cur
|
|
140
|
+
* - outbox/sent
|
|
141
|
+
*/
|
|
142
|
+
export function ensureAgentMailbox(amqRoot, handle) {
|
|
143
|
+
const agentDir = path.join(amqRoot, "agents", handle);
|
|
144
|
+
const dirs = [
|
|
145
|
+
path.join(agentDir, "inbox", "tmp"),
|
|
146
|
+
path.join(agentDir, "inbox", "new"),
|
|
147
|
+
path.join(agentDir, "inbox", "cur"),
|
|
148
|
+
path.join(agentDir, "outbox", "sent"),
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
for (const d of dirs) {
|
|
152
|
+
if (!fs.existsSync(d)) {
|
|
153
|
+
fs.mkdirSync(d, { recursive: true });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return agentDir;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Send an AMQ message natively using pure Maildir atomic delivery.
|
|
162
|
+
* Adheres to DJB Maildir spec:
|
|
163
|
+
* 1. Write to inbox/tmp/<id>.md
|
|
164
|
+
* 2. Atomic rename to inbox/new/<id>.md
|
|
165
|
+
* 3. Store copy in outbox/sent/<id>.md
|
|
166
|
+
*/
|
|
167
|
+
export function sendMaildirMessage(amqRoot, options = {}) {
|
|
168
|
+
if (!amqRoot) throw new Error("amqRoot is required");
|
|
169
|
+
const { from, to, subject, body, priority, kind, thread, refs, labels, context, attachments = [] } = options;
|
|
170
|
+
|
|
171
|
+
if (!from) throw new Error("Sender 'from' is required");
|
|
172
|
+
const recipients = Array.isArray(to) ? to : (to ? [to] : []);
|
|
173
|
+
if (!recipients.length) throw new Error("At least one recipient in 'to' is required");
|
|
174
|
+
|
|
175
|
+
const msgId = options.id || generateMessageId();
|
|
176
|
+
const created = options.created || new Date().toISOString();
|
|
177
|
+
|
|
178
|
+
// Process attachments: auto-freeze ephemeral files into CAS blobstore if needed
|
|
179
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
180
|
+
const processedAttachments = [];
|
|
181
|
+
for (const att of attachments) {
|
|
182
|
+
const ingested = ingestAttachment(att, amqRoot, repoRoot);
|
|
183
|
+
if (ingested) {
|
|
184
|
+
processedAttachments.push(ingested);
|
|
185
|
+
} else if (typeof att === "string") {
|
|
186
|
+
processedAttachments.push({ path: att, name: path.basename(att) });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const fileText = serializeMessage({
|
|
191
|
+
id: msgId,
|
|
192
|
+
from,
|
|
193
|
+
to: recipients,
|
|
194
|
+
subject: subject || "(no subject)",
|
|
195
|
+
body: body || "",
|
|
196
|
+
thread: thread || computeCanonicalThread(from, recipients),
|
|
197
|
+
refs: refs || [],
|
|
198
|
+
priority: priority || "normal",
|
|
199
|
+
kind,
|
|
200
|
+
labels: labels || [],
|
|
201
|
+
attachments: processedAttachments,
|
|
202
|
+
context: context || null,
|
|
203
|
+
created,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Ensure sender mailbox exists & record in outbox/sent
|
|
207
|
+
ensureAgentMailbox(amqRoot, from);
|
|
208
|
+
const senderSentDir = path.join(amqRoot, "agents", from, "outbox", "sent");
|
|
209
|
+
fs.writeFileSync(path.join(senderSentDir, `${msgId}.md`), fileText, "utf8");
|
|
210
|
+
|
|
211
|
+
// Deliver to each recipient using atomic Maildir tmp -> new rename
|
|
212
|
+
for (const recipient of recipients) {
|
|
213
|
+
ensureAgentMailbox(amqRoot, recipient);
|
|
214
|
+
const tmpPath = path.join(amqRoot, "agents", recipient, "inbox", "tmp", `${msgId}.md`);
|
|
215
|
+
const newPath = path.join(amqRoot, "agents", recipient, "inbox", "new", `${msgId}.md`);
|
|
216
|
+
|
|
217
|
+
// Write to tmp
|
|
218
|
+
fs.writeFileSync(tmpPath, fileText, "utf8");
|
|
219
|
+
// Atomic move to new (POSIX atomic rename guarantee)
|
|
220
|
+
fs.renameSync(tmpPath, newPath);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
id: msgId,
|
|
226
|
+
from,
|
|
227
|
+
to: recipients,
|
|
228
|
+
subject,
|
|
229
|
+
thread,
|
|
230
|
+
refs,
|
|
231
|
+
created,
|
|
232
|
+
attachmentsCount: processedAttachments.length,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Locate any message across all agent maildirs by ID.
|
|
238
|
+
*/
|
|
239
|
+
export function findMessageById(amqRoot, msgId) {
|
|
240
|
+
if (!amqRoot || !msgId) return null;
|
|
241
|
+
const agentsDir = path.join(amqRoot, "agents");
|
|
242
|
+
if (!fs.existsSync(agentsDir)) return null;
|
|
243
|
+
|
|
244
|
+
const agentFolders = fs.readdirSync(agentsDir);
|
|
245
|
+
for (const agent of agentFolders) {
|
|
246
|
+
const candidateDirs = [
|
|
247
|
+
path.join(agentsDir, agent, "inbox", "new"),
|
|
248
|
+
path.join(agentsDir, agent, "inbox", "cur"),
|
|
249
|
+
path.join(agentsDir, agent, "outbox", "sent"),
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
for (const dir of candidateDirs) {
|
|
253
|
+
const filePath = path.join(dir, `${msgId}.md`);
|
|
254
|
+
if (fs.existsSync(filePath)) {
|
|
255
|
+
try {
|
|
256
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
257
|
+
const { header, body } = parseMessage(content);
|
|
258
|
+
return {
|
|
259
|
+
id: msgId,
|
|
260
|
+
header,
|
|
261
|
+
body,
|
|
262
|
+
filePath,
|
|
263
|
+
foundIn: agent,
|
|
264
|
+
};
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Reply to an existing message using RFC 5322 In-Reply-To / References chaining.
|
|
275
|
+
*/
|
|
276
|
+
export function replyMaildirMessage(amqRoot, options = {}) {
|
|
277
|
+
const { from, replyToId, body, subject, priority, kind, labels, attachments } = options;
|
|
278
|
+
if (!replyToId) throw new Error("replyToId is required");
|
|
279
|
+
if (!from) throw new Error("Sender 'from' is required");
|
|
280
|
+
|
|
281
|
+
const original = findMessageById(amqRoot, replyToId);
|
|
282
|
+
if (!original) {
|
|
283
|
+
throw new Error(`Original message with ID '${replyToId}' not found`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const origHeader = original.header;
|
|
287
|
+
// Reply to original sender
|
|
288
|
+
const recipients = [origHeader.from || "coordinator"];
|
|
289
|
+
// Preserve thread or compute canonical
|
|
290
|
+
const thread = origHeader.thread || computeCanonicalThread(from, recipients);
|
|
291
|
+
// RFC 5322 References chaining: append current ID to refs
|
|
292
|
+
const existingRefs = Array.isArray(origHeader.refs) ? origHeader.refs : [];
|
|
293
|
+
const refs = [...new Set([...existingRefs, replyToId])];
|
|
294
|
+
|
|
295
|
+
const safeSubject = subject || (
|
|
296
|
+
origHeader.subject?.toLowerCase().startsWith("re:")
|
|
297
|
+
? origHeader.subject
|
|
298
|
+
: `Re: ${origHeader.subject || ""}`
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return sendMaildirMessage(amqRoot, {
|
|
302
|
+
from,
|
|
303
|
+
to: recipients,
|
|
304
|
+
subject: safeSubject,
|
|
305
|
+
body: body || "",
|
|
306
|
+
thread,
|
|
307
|
+
refs,
|
|
308
|
+
priority: priority || origHeader.priority || "normal",
|
|
309
|
+
kind: kind || origHeader.kind || null,
|
|
310
|
+
labels: labels || origHeader.labels || [],
|
|
311
|
+
attachments: attachments || [],
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Drain all new messages for an agent (atomic Maildir new/ -> cur/ transition).
|
|
317
|
+
*/
|
|
318
|
+
export function drainMaildir(amqRoot, handle) {
|
|
319
|
+
if (!amqRoot || !handle) return [];
|
|
320
|
+
const newDir = path.join(amqRoot, "agents", handle, "inbox", "new");
|
|
321
|
+
const curDir = path.join(amqRoot, "agents", handle, "inbox", "cur");
|
|
322
|
+
|
|
323
|
+
if (!fs.existsSync(newDir)) return [];
|
|
324
|
+
if (!fs.existsSync(curDir)) fs.mkdirSync(curDir, { recursive: true });
|
|
325
|
+
|
|
326
|
+
const files = fs.readdirSync(newDir).filter((f) => f.endsWith(".md"));
|
|
327
|
+
const drained = [];
|
|
328
|
+
|
|
329
|
+
for (const f of files) {
|
|
330
|
+
const fromPath = path.join(newDir, f);
|
|
331
|
+
const toPath = path.join(curDir, f);
|
|
332
|
+
try {
|
|
333
|
+
const content = fs.readFileSync(fromPath, "utf8");
|
|
334
|
+
const { header, body } = parseMessage(content);
|
|
335
|
+
// Atomic move to cur
|
|
336
|
+
fs.renameSync(fromPath, toPath);
|
|
337
|
+
drained.push({
|
|
338
|
+
id: f.replace(/\.md$/, ""),
|
|
339
|
+
header,
|
|
340
|
+
body,
|
|
341
|
+
filePath: toPath,
|
|
342
|
+
});
|
|
343
|
+
} catch {}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return drained;
|
|
347
|
+
}
|