herdr-plugin-amq 0.1.8 → 0.1.10
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 +35 -268
- package/docs/architecture.md +50 -0
- package/docs/cli-and-workflows.md +72 -0
- package/docs/images/agmail-agent-activity.webp +0 -0
- package/docs/images/agmail-mobile-new-task.webp +0 -0
- package/docs/images/agmail-task-drawer.webp +0 -0
- package/docs/installation.md +70 -0
- package/docs/security-and-testing.md +33 -0
- package/docs/ui-screenshots.md +21 -0
- package/package.json +11 -5
- package/skills/herdr-amq/SKILL.md +4 -3
- package/src/actions.mjs +2 -2
- package/src/bridge.mjs +159 -53
- package/src/fleet.mjs +12 -10
- package/src/herdr.mjs +68 -14
- package/src/index.mjs +1 -0
- package/src/protocol.mjs +347 -67
- package/src/runtime-models.mjs +118 -0
- package/src/server.mjs +81 -37
- package/src/store.mjs +219 -34
- package/src/templates.mjs +118 -0
- package/src/web/app.js +487 -105
- package/src/web/index.html +130 -11
- package/src/web/style.css +1022 -39
package/src/protocol.mjs
CHANGED
|
@@ -38,6 +38,232 @@ export function generateMessageId(date = new Date(), pid = process.pid) {
|
|
|
38
38
|
return `${iso}_pid${pid}_${rand}`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function isSafeMailIdentifier(value, maxLength = 200) {
|
|
42
|
+
if (typeof value !== "string") return false;
|
|
43
|
+
if (!value || value.length > maxLength || value === "." || value === "..") return false;
|
|
44
|
+
return !value.includes("/") && !value.includes("\\") && !value.includes("\0");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveAgentDirectory(amqRoot, handle, create = false) {
|
|
48
|
+
if (!amqRoot || !fs.existsSync(amqRoot) || !isSafeMailIdentifier(handle, 128)) {
|
|
49
|
+
throw new Error("Invalid AMQ agent path");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const root = fs.realpathSync(amqRoot);
|
|
53
|
+
const agentsDir = path.join(root, "agents");
|
|
54
|
+
if (create) fs.mkdirSync(agentsDir, { recursive: true });
|
|
55
|
+
if (!fs.existsSync(agentsDir)) return null;
|
|
56
|
+
|
|
57
|
+
const agentsStat = fs.lstatSync(agentsDir);
|
|
58
|
+
if (!agentsStat.isDirectory() || agentsStat.isSymbolicLink()) {
|
|
59
|
+
throw new Error("Invalid AMQ agents directory");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const agentDir = path.join(agentsDir, handle);
|
|
63
|
+
if (create && !fs.existsSync(agentDir)) ensureDirectoryExists(agentDir);
|
|
64
|
+
if (!fs.existsSync(agentDir)) return null;
|
|
65
|
+
|
|
66
|
+
const agentStat = fs.lstatSync(agentDir);
|
|
67
|
+
if (!agentStat.isDirectory() || agentStat.isSymbolicLink()) {
|
|
68
|
+
throw new Error("Invalid AMQ agent directory");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const realAgentsDir = fs.realpathSync(agentsDir);
|
|
72
|
+
const realAgentDir = fs.realpathSync(agentDir);
|
|
73
|
+
if (path.dirname(realAgentDir) !== realAgentsDir) {
|
|
74
|
+
throw new Error("AMQ agent directory escaped the queue root");
|
|
75
|
+
}
|
|
76
|
+
return realAgentDir;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const MAX_MAILDIR_FILE_BYTES = 8 * 1024 * 1024;
|
|
80
|
+
|
|
81
|
+
function openNoFollow(filePath, flags, mode) {
|
|
82
|
+
const noFollow = fs.constants.O_NOFOLLOW || 0;
|
|
83
|
+
return fs.openSync(filePath, flags | noFollow, mode);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isDirectoryHandle(handle) {
|
|
87
|
+
return Boolean(handle && typeof handle === "object");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function closeDirectory(handle) {
|
|
91
|
+
if (!isDirectoryHandle(handle)) fs.closeSync(handle);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function descriptorPath(handle) {
|
|
95
|
+
if (isDirectoryHandle(handle)) return handle.path;
|
|
96
|
+
const base = process.platform === "darwin" ? "/dev/fd" : "/proc/self/fd";
|
|
97
|
+
return path.join(base, String(handle));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function openRelativeNoFollow(dirHandle, name, flags, mode) {
|
|
101
|
+
if (!name || path.basename(name) !== name) throw new Error("Invalid relative Maildir name");
|
|
102
|
+
return openNoFollow(path.join(descriptorPath(dirHandle), name), flags, mode);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveDarwinDirectory(dirPath) {
|
|
106
|
+
const resolved = path.resolve(dirPath);
|
|
107
|
+
const parsed = path.parse(resolved);
|
|
108
|
+
const segments = resolved.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
109
|
+
const systemSymlinkRoots = new Set(["/private", "/tmp", "/var"]);
|
|
110
|
+
let current = parsed.root;
|
|
111
|
+
for (const segment of segments) {
|
|
112
|
+
current = path.join(current, segment);
|
|
113
|
+
const stat = fs.lstatSync(current);
|
|
114
|
+
if (stat.isSymbolicLink()) {
|
|
115
|
+
if (!systemSymlinkRoots.has(current)) throw new Error("Symlinked directory is not allowed");
|
|
116
|
+
current = fs.realpathSync(current);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!stat.isDirectory()) throw new Error("Maildir path is not a directory");
|
|
120
|
+
}
|
|
121
|
+
const realPath = fs.realpathSync(current);
|
|
122
|
+
if (!fs.statSync(realPath).isDirectory()) throw new Error("Maildir path is not a directory");
|
|
123
|
+
return realPath;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function openDirectorySecure(dirPath) {
|
|
127
|
+
if (process.platform === "darwin") return { path: resolveDarwinDirectory(dirPath) };
|
|
128
|
+
const resolved = path.resolve(dirPath);
|
|
129
|
+
const parsed = path.parse(resolved);
|
|
130
|
+
const segments = resolved.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
131
|
+
const directoryFlag = fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY || 0);
|
|
132
|
+
let current = openNoFollow(parsed.root, directoryFlag, undefined);
|
|
133
|
+
try {
|
|
134
|
+
for (const segment of segments) {
|
|
135
|
+
const next = openRelativeNoFollow(current, segment, directoryFlag);
|
|
136
|
+
closeDirectory(current);
|
|
137
|
+
current = next;
|
|
138
|
+
}
|
|
139
|
+
return current;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
closeDirectory(current);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function writeFileAtomicBetweenDirectories(fileName, tempDirFd, targetDirFd, content, maxBytes = MAX_MAILDIR_FILE_BYTES) {
|
|
147
|
+
if (Buffer.byteLength(content, "utf8") > maxBytes) {
|
|
148
|
+
throw new Error("Message exceeds the Maildir size limit");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const tempName = `.amq-${process.pid}-${crypto.randomBytes(8).toString("hex")}.tmp`;
|
|
152
|
+
let fd;
|
|
153
|
+
try {
|
|
154
|
+
fd = openRelativeNoFollow(
|
|
155
|
+
tempDirFd,
|
|
156
|
+
tempName,
|
|
157
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
|
158
|
+
0o600
|
|
159
|
+
);
|
|
160
|
+
fs.writeFileSync(fd, content, "utf8");
|
|
161
|
+
fs.fsyncSync(fd);
|
|
162
|
+
fs.closeSync(fd);
|
|
163
|
+
fd = undefined;
|
|
164
|
+
fs.renameSync(
|
|
165
|
+
path.join(descriptorPath(tempDirFd), tempName),
|
|
166
|
+
path.join(descriptorPath(targetDirFd), fileName)
|
|
167
|
+
);
|
|
168
|
+
} finally {
|
|
169
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
170
|
+
try { fs.unlinkSync(path.join(descriptorPath(tempDirFd), tempName)); } catch {}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function writeBoundedFileAtomic(filePath, content, maxBytes = MAX_MAILDIR_FILE_BYTES) {
|
|
175
|
+
const dirFd = openDirectorySecure(path.dirname(filePath));
|
|
176
|
+
try {
|
|
177
|
+
writeFileAtomicBetweenDirectories(path.basename(filePath), dirFd, dirFd, content, maxBytes);
|
|
178
|
+
} finally {
|
|
179
|
+
closeDirectory(dirFd);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function listMaildirMessageFiles(dirPath) {
|
|
184
|
+
const dirFd = openDirectorySecure(dirPath);
|
|
185
|
+
try {
|
|
186
|
+
return fs.readdirSync(descriptorPath(dirFd));
|
|
187
|
+
} finally {
|
|
188
|
+
closeDirectory(dirFd);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function moveMaildirMessage(amqRoot, handle, fromFolder, toFolder, fileName) {
|
|
193
|
+
if (!isSafeMailIdentifier(handle, 128) || !isSafeMailIdentifier(fileName)) {
|
|
194
|
+
throw new Error("Invalid Maildir message path");
|
|
195
|
+
}
|
|
196
|
+
const agentDir = resolveAgentDirectory(amqRoot, handle, false);
|
|
197
|
+
if (!agentDir) return null;
|
|
198
|
+
const fromDir = ensureContainedDirectory(agentDir, ["inbox", fromFolder]);
|
|
199
|
+
const toDir = ensureContainedDirectory(agentDir, ["inbox", toFolder]);
|
|
200
|
+
const fromFd = openDirectorySecure(fromDir);
|
|
201
|
+
let toFd;
|
|
202
|
+
try {
|
|
203
|
+
toFd = openDirectorySecure(toDir);
|
|
204
|
+
fs.renameSync(
|
|
205
|
+
path.join(descriptorPath(fromFd), fileName),
|
|
206
|
+
path.join(descriptorPath(toFd), fileName)
|
|
207
|
+
);
|
|
208
|
+
return path.join(toDir, fileName);
|
|
209
|
+
} finally {
|
|
210
|
+
closeDirectory(fromFd);
|
|
211
|
+
if (toFd !== undefined) closeDirectory(toFd);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function readMaildirMessageFile(filePath, maxBytes = MAX_MAILDIR_FILE_BYTES) {
|
|
216
|
+
const dirFd = openDirectorySecure(path.dirname(filePath));
|
|
217
|
+
let fd;
|
|
218
|
+
try {
|
|
219
|
+
fd = openRelativeNoFollow(
|
|
220
|
+
dirFd,
|
|
221
|
+
path.basename(filePath),
|
|
222
|
+
fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0)
|
|
223
|
+
);
|
|
224
|
+
const stat = fs.fstatSync(fd);
|
|
225
|
+
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
226
|
+
|
|
227
|
+
const chunks = [];
|
|
228
|
+
let total = 0;
|
|
229
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
230
|
+
while (true) {
|
|
231
|
+
const bytes = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
232
|
+
if (bytes === 0) break;
|
|
233
|
+
total += bytes;
|
|
234
|
+
if (total > maxBytes) return null;
|
|
235
|
+
chunks.push(Buffer.from(buffer.subarray(0, bytes)));
|
|
236
|
+
}
|
|
237
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
238
|
+
} finally {
|
|
239
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
240
|
+
closeDirectory(dirFd);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function ensureDirectoryExists(dir) {
|
|
245
|
+
try {
|
|
246
|
+
fs.mkdirSync(dir);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error.code !== "EEXIST") throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function ensureContainedDirectory(rootDir, segments) {
|
|
253
|
+
let current = rootDir;
|
|
254
|
+
for (const segment of segments) {
|
|
255
|
+
current = path.join(current, segment);
|
|
256
|
+
if (!fs.existsSync(current)) ensureDirectoryExists(current);
|
|
257
|
+
const stat = fs.lstatSync(current);
|
|
258
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Invalid AMQ mailbox directory");
|
|
259
|
+
const real = fs.realpathSync(current);
|
|
260
|
+
if (real !== current || !real.startsWith(`${rootDir}${path.sep}`)) {
|
|
261
|
+
throw new Error("AMQ mailbox directory escaped the queue root");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return current;
|
|
265
|
+
}
|
|
266
|
+
|
|
41
267
|
/**
|
|
42
268
|
* Determine a canonical p2p or group thread ID.
|
|
43
269
|
* For 2 participants, sorts lexicographically: p2p/<agentA>__<agentB>
|
|
@@ -140,20 +366,15 @@ export function parseMessage(content = "") {
|
|
|
140
366
|
* - outbox/sent
|
|
141
367
|
*/
|
|
142
368
|
export function ensureAgentMailbox(amqRoot, handle) {
|
|
143
|
-
const agentDir =
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
];
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (!fs.existsSync(d)) {
|
|
153
|
-
fs.mkdirSync(d, { recursive: true });
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
369
|
+
const agentDir = resolveAgentDirectory(amqRoot, handle, true);
|
|
370
|
+
ensureContainedDirectory(agentDir, ["inbox"]);
|
|
371
|
+
ensureContainedDirectory(agentDir, ["inbox", "tmp"]);
|
|
372
|
+
ensureContainedDirectory(agentDir, ["inbox", "new"]);
|
|
373
|
+
ensureContainedDirectory(agentDir, ["inbox", "cur"]);
|
|
374
|
+
ensureContainedDirectory(agentDir, ["outbox"]);
|
|
375
|
+
ensureContainedDirectory(agentDir, ["outbox", "tmp"]);
|
|
376
|
+
ensureContainedDirectory(agentDir, ["outbox", "sent"]);
|
|
377
|
+
ensureContainedDirectory(agentDir, ["receipts"]);
|
|
157
378
|
return agentDir;
|
|
158
379
|
}
|
|
159
380
|
|
|
@@ -169,10 +390,15 @@ export function sendMaildirMessage(amqRoot, options = {}) {
|
|
|
169
390
|
const { from, to, subject, body, priority, kind, thread, refs, labels, context, attachments = [] } = options;
|
|
170
391
|
|
|
171
392
|
if (!from) throw new Error("Sender 'from' is required");
|
|
393
|
+
if (!isSafeMailIdentifier(from, 128)) throw new Error("Invalid sender handle");
|
|
172
394
|
const recipients = Array.isArray(to) ? to : (to ? [to] : []);
|
|
173
395
|
if (!recipients.length) throw new Error("At least one recipient in 'to' is required");
|
|
396
|
+
if (!recipients.every((recipient) => isSafeMailIdentifier(recipient, 128))) {
|
|
397
|
+
throw new Error("Invalid recipient handle");
|
|
398
|
+
}
|
|
174
399
|
|
|
175
400
|
const msgId = options.id || generateMessageId();
|
|
401
|
+
if (!isSafeMailIdentifier(msgId)) throw new Error("Invalid message id");
|
|
176
402
|
const created = options.created || new Date().toISOString();
|
|
177
403
|
|
|
178
404
|
// Process attachments: auto-freeze ephemeral files into CAS blobstore if needed
|
|
@@ -203,21 +429,29 @@ export function sendMaildirMessage(amqRoot, options = {}) {
|
|
|
203
429
|
created,
|
|
204
430
|
});
|
|
205
431
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
fs.writeFileSync(path.join(senderSentDir, `${msgId}.md`), fileText, "utf8");
|
|
432
|
+
if (Buffer.byteLength(fileText, "utf8") > MAX_MAILDIR_FILE_BYTES) {
|
|
433
|
+
throw new Error("Message exceeds the Maildir size limit");
|
|
434
|
+
}
|
|
210
435
|
|
|
211
|
-
// Deliver to each recipient using atomic Maildir tmp -> new rename
|
|
212
436
|
for (const recipient of recipients) {
|
|
213
|
-
ensureAgentMailbox(amqRoot, recipient);
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
437
|
+
const recipientDir = ensureAgentMailbox(amqRoot, recipient);
|
|
438
|
+
const tmpFd = openDirectorySecure(path.join(recipientDir, "inbox", "tmp"));
|
|
439
|
+
let newFd;
|
|
440
|
+
try {
|
|
441
|
+
newFd = openDirectorySecure(path.join(recipientDir, "inbox", "new"));
|
|
442
|
+
writeFileAtomicBetweenDirectories(`${msgId}.md`, tmpFd, newFd, fileText);
|
|
443
|
+
} finally {
|
|
444
|
+
closeDirectory(tmpFd);
|
|
445
|
+
if (newFd !== undefined) closeDirectory(newFd);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const senderDir = ensureAgentMailbox(amqRoot, from);
|
|
450
|
+
const sentFd = openDirectorySecure(path.join(senderDir, "outbox", "sent"));
|
|
451
|
+
try {
|
|
452
|
+
writeFileAtomicBetweenDirectories(`${msgId}.md`, sentFd, sentFd, fileText);
|
|
453
|
+
} finally {
|
|
454
|
+
closeDirectory(sentFd);
|
|
221
455
|
}
|
|
222
456
|
|
|
223
457
|
return {
|
|
@@ -236,35 +470,86 @@ export function sendMaildirMessage(amqRoot, options = {}) {
|
|
|
236
470
|
/**
|
|
237
471
|
* Locate any message across all agent maildirs by ID.
|
|
238
472
|
*/
|
|
239
|
-
export function
|
|
240
|
-
if (!amqRoot || !msgId)
|
|
241
|
-
|
|
242
|
-
|
|
473
|
+
export function markMaildirMessageRead(amqRoot, handle, msgId) {
|
|
474
|
+
if (!amqRoot || !isSafeMailIdentifier(handle, 128) || !isSafeMailIdentifier(msgId)) {
|
|
475
|
+
return { ok: false, error: "Invalid mailbox or message identifier" };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
const agentDir = resolveAgentDirectory(amqRoot, handle, false);
|
|
480
|
+
if (!agentDir) return { ok: false, error: "Mailbox not found" };
|
|
481
|
+
const newPath = path.join(agentDir, "inbox", "new", `${msgId}.md`);
|
|
482
|
+
const curPath = path.join(agentDir, "inbox", "cur", `${msgId}.md`);
|
|
483
|
+
const logicalCurPath = path.join(amqRoot, "agents", handle, "inbox", "cur", `${msgId}.md`);
|
|
484
|
+
if (!fs.existsSync(newPath)) {
|
|
485
|
+
if (fs.existsSync(curPath)) return { ok: true, alreadyRead: true, id: msgId, filePath: logicalCurPath };
|
|
486
|
+
return { ok: false, error: "Message not found" };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const content = readMaildirMessageFile(newPath);
|
|
490
|
+
if (content === null) return { ok: false, error: "Message could not be read" };
|
|
491
|
+
const { header } = parseMessage(content);
|
|
492
|
+
const recipients = Array.isArray(header.to) ? header.to : [header.to];
|
|
493
|
+
if (header.id !== msgId || !recipients.includes(handle)) {
|
|
494
|
+
return { ok: false, error: "Message is not addressed to this mailbox" };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const filePath = moveMaildirMessage(amqRoot, handle, "new", "cur", `${msgId}.md`);
|
|
498
|
+
if (!filePath) return { ok: false, error: "Message could not be marked read" };
|
|
499
|
+
return { ok: true, alreadyRead: false, id: msgId, filePath: logicalCurPath };
|
|
500
|
+
} catch (error) {
|
|
501
|
+
return { ok: false, error: error.message };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function findMessageForRecipient(amqRoot, recipient, msgId) {
|
|
506
|
+
if (!amqRoot || !isSafeMailIdentifier(recipient, 128) || !isSafeMailIdentifier(msgId)) return null;
|
|
243
507
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
path.join(agentsDir, agent, "inbox", "new"),
|
|
248
|
-
path.join(agentsDir, agent, "inbox", "cur"),
|
|
249
|
-
path.join(agentsDir, agent, "outbox", "sent"),
|
|
250
|
-
];
|
|
508
|
+
try {
|
|
509
|
+
const agentDir = resolveAgentDirectory(amqRoot, recipient, false);
|
|
510
|
+
if (!agentDir) return null;
|
|
251
511
|
|
|
252
|
-
for (const dir of
|
|
512
|
+
for (const dir of [path.join(agentDir, "inbox", "new"), path.join(agentDir, "inbox", "cur")]) {
|
|
253
513
|
const filePath = path.join(dir, `${msgId}.md`);
|
|
254
|
-
if (fs.existsSync(filePath))
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
body,
|
|
262
|
-
filePath,
|
|
263
|
-
foundIn: agent,
|
|
264
|
-
};
|
|
265
|
-
} catch {}
|
|
514
|
+
if (!fs.existsSync(filePath)) continue;
|
|
515
|
+
const content = readMaildirMessageFile(filePath);
|
|
516
|
+
if (content === null) continue;
|
|
517
|
+
const { header, body } = parseMessage(content);
|
|
518
|
+
const recipients = Array.isArray(header.to) ? header.to : [header.to];
|
|
519
|
+
if (header.id === msgId && recipients.includes(recipient)) {
|
|
520
|
+
return { id: msgId, header, body, filePath, foundIn: recipient };
|
|
266
521
|
}
|
|
267
522
|
}
|
|
523
|
+
} catch {}
|
|
524
|
+
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function findMessageById(amqRoot, msgId) {
|
|
529
|
+
if (!amqRoot || !isSafeMailIdentifier(msgId)) return null;
|
|
530
|
+
const agentsDir = path.join(amqRoot, "agents");
|
|
531
|
+
if (!fs.existsSync(agentsDir)) return null;
|
|
532
|
+
|
|
533
|
+
for (const agent of fs.readdirSync(agentsDir)) {
|
|
534
|
+
if (!isSafeMailIdentifier(agent, 128)) continue;
|
|
535
|
+
try {
|
|
536
|
+
const agentDir = resolveAgentDirectory(amqRoot, agent, false);
|
|
537
|
+
if (!agentDir) continue;
|
|
538
|
+
const candidateDirs = [
|
|
539
|
+
path.join(agentDir, "inbox", "new"),
|
|
540
|
+
path.join(agentDir, "inbox", "cur"),
|
|
541
|
+
path.join(agentDir, "outbox", "sent"),
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
for (const dir of candidateDirs) {
|
|
545
|
+
const filePath = path.join(dir, `${msgId}.md`);
|
|
546
|
+
if (!fs.existsSync(filePath)) continue;
|
|
547
|
+
const content = readMaildirMessageFile(filePath);
|
|
548
|
+
if (content === null) continue;
|
|
549
|
+
const { header, body } = parseMessage(content);
|
|
550
|
+
if (header.id === msgId) return { id: msgId, header, body, filePath, foundIn: agent };
|
|
551
|
+
}
|
|
552
|
+
} catch {}
|
|
268
553
|
}
|
|
269
554
|
|
|
270
555
|
return null;
|
|
@@ -316,29 +601,24 @@ export function replyMaildirMessage(amqRoot, options = {}) {
|
|
|
316
601
|
* Drain all new messages for an agent (atomic Maildir new/ -> cur/ transition).
|
|
317
602
|
*/
|
|
318
603
|
export function drainMaildir(amqRoot, handle) {
|
|
319
|
-
if (!amqRoot || !handle) return [];
|
|
320
|
-
const
|
|
321
|
-
const
|
|
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"));
|
|
604
|
+
if (!amqRoot || !isSafeMailIdentifier(handle, 128)) return [];
|
|
605
|
+
const agentDir = resolveAgentDirectory(amqRoot, handle, true);
|
|
606
|
+
const newDir = ensureContainedDirectory(agentDir, ["inbox", "new"]);
|
|
607
|
+
const files = listMaildirMessageFiles(newDir).filter((file) => file.endsWith(".md"));
|
|
327
608
|
const drained = [];
|
|
328
609
|
|
|
329
|
-
for (const
|
|
330
|
-
const fromPath = path.join(newDir, f);
|
|
331
|
-
const toPath = path.join(curDir, f);
|
|
610
|
+
for (const file of files) {
|
|
332
611
|
try {
|
|
333
|
-
const content =
|
|
612
|
+
const content = readMaildirMessageFile(path.join(newDir, file));
|
|
613
|
+
if (content === null) continue;
|
|
334
614
|
const { header, body } = parseMessage(content);
|
|
335
|
-
|
|
336
|
-
|
|
615
|
+
const filePath = moveMaildirMessage(amqRoot, handle, "new", "cur", file);
|
|
616
|
+
if (!filePath) continue;
|
|
337
617
|
drained.push({
|
|
338
|
-
id:
|
|
618
|
+
id: file.replace(/\.md$/, ""),
|
|
339
619
|
header,
|
|
340
620
|
body,
|
|
341
|
-
filePath
|
|
621
|
+
filePath,
|
|
342
622
|
});
|
|
343
623
|
} catch {}
|
|
344
624
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]{1,200}$/;
|
|
4
|
+
const MODEL_CACHE_TTL_MS = 5000;
|
|
5
|
+
const modelCache = new Map();
|
|
6
|
+
let modelCacheExpiresAt = 0;
|
|
7
|
+
|
|
8
|
+
function text(value) {
|
|
9
|
+
return typeof value === "string" ? value.trim() : "";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function objectValue(value) {
|
|
13
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseJson(value) {
|
|
17
|
+
if (objectValue(value)) return value;
|
|
18
|
+
if (typeof value !== "string") return null;
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
if (!trimmed) return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(trimmed);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalizeRuntimeModel(value) {
|
|
29
|
+
const parsed = parseJson(value);
|
|
30
|
+
if (typeof parsed === "string") return normalizeRuntimeModel(parsed);
|
|
31
|
+
if (!objectValue(parsed)) return text(value) || null;
|
|
32
|
+
|
|
33
|
+
const id = text(parsed.id) || text(parsed.model) || text(parsed.name);
|
|
34
|
+
const provider = text(parsed.providerID) || text(parsed.provider);
|
|
35
|
+
const variant = text(parsed.variant) || text(parsed.reasoning);
|
|
36
|
+
if (!id) return null;
|
|
37
|
+
const qualified = provider && !id.includes("/") ? `${provider}/${id}` : id;
|
|
38
|
+
return variant ? `${qualified} (${variant})` : qualified;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getHarnessSessionId(agent) {
|
|
42
|
+
const session = objectValue(agent?.agent_session) || objectValue(agent?.session);
|
|
43
|
+
return text(session?.value) || text(session?.id) || text(session?.session_id) || text(agent?.session_id) || text(agent?.sessionId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function directModel(agent) {
|
|
47
|
+
const session = objectValue(agent?.agent_session) || objectValue(agent?.session);
|
|
48
|
+
const metadata = objectValue(agent?.metadata);
|
|
49
|
+
const config = objectValue(agent?.config);
|
|
50
|
+
const candidates = [
|
|
51
|
+
agent?.model,
|
|
52
|
+
agent?.model_id,
|
|
53
|
+
agent?.modelId,
|
|
54
|
+
session?.model,
|
|
55
|
+
metadata?.model,
|
|
56
|
+
config?.model,
|
|
57
|
+
];
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
const model = normalizeRuntimeModel(candidate);
|
|
60
|
+
if (model) return model;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveRuntimeModel(agent, sessionModels = new Map()) {
|
|
66
|
+
const direct = directModel(agent);
|
|
67
|
+
const sessionId = getHarnessSessionId(agent);
|
|
68
|
+
if (direct) return { model: direct, source: "herdr-record", sessionId };
|
|
69
|
+
const sessionModel = sessionId ? sessionModels.get(sessionId) : null;
|
|
70
|
+
if (sessionModel) return { model: sessionModel, source: "opencode-session", sessionId };
|
|
71
|
+
return { model: null, source: null, sessionId };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isOpenCodeAgent(agent) {
|
|
75
|
+
const session = objectValue(agent?.agent_session);
|
|
76
|
+
return [agent?.agent, session?.agent, session?.source].some((value) => text(value).toLowerCase().includes("opencode"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function queryOpenCodeModels(ids, execFile = execFileSync) {
|
|
80
|
+
if (!ids.length) return [];
|
|
81
|
+
const bin = process.env.OPENCODE_BIN_PATH || process.env.OPENCODE_BIN || "opencode";
|
|
82
|
+
const list = ids.map((id) => `'${id}'`).join(",");
|
|
83
|
+
const output = execFile(bin, ["db", `SELECT id, model FROM session WHERE id IN (${list})`, "--format", "json"], {
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
86
|
+
});
|
|
87
|
+
const parsed = parseJson(output);
|
|
88
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function clearRuntimeModelCache() {
|
|
92
|
+
modelCache.clear();
|
|
93
|
+
modelCacheExpiresAt = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getOpenCodeSessionModels(agents, { now = Date.now(), queryModels = queryOpenCodeModels } = {}) {
|
|
97
|
+
const ids = [...new Set((Array.isArray(agents) ? agents : [])
|
|
98
|
+
.filter(isOpenCodeAgent)
|
|
99
|
+
.map(getHarnessSessionId)
|
|
100
|
+
.filter((id) => SESSION_ID_PATTERN.test(id)))];
|
|
101
|
+
if (!ids.length) return new Map();
|
|
102
|
+
|
|
103
|
+
if (now >= modelCacheExpiresAt) {
|
|
104
|
+
modelCache.clear();
|
|
105
|
+
modelCacheExpiresAt = now + MODEL_CACHE_TTL_MS;
|
|
106
|
+
}
|
|
107
|
+
const missing = ids.filter((id) => !modelCache.has(id));
|
|
108
|
+
if (missing.length) {
|
|
109
|
+
try {
|
|
110
|
+
for (const row of queryModels(missing)) {
|
|
111
|
+
const id = text(row?.id);
|
|
112
|
+
const model = normalizeRuntimeModel(row?.model);
|
|
113
|
+
if (id && model) modelCache.set(id, model);
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
return new Map(ids.filter((id) => modelCache.has(id)).map((id) => [id, modelCache.get(id)]));
|
|
118
|
+
}
|