herdr-plugin-amq 0.1.8 → 0.1.9
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 +315 -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,201 @@ 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 descriptorPath(fd) {
|
|
87
|
+
const base = process.platform === "darwin" ? "/dev/fd" : "/proc/self/fd";
|
|
88
|
+
return path.join(base, String(fd));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function openRelativeNoFollow(dirFd, name, flags, mode) {
|
|
92
|
+
if (!name || path.basename(name) !== name) throw new Error("Invalid relative Maildir name");
|
|
93
|
+
return openNoFollow(path.join(descriptorPath(dirFd), name), flags, mode);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function openDirectorySecure(dirPath) {
|
|
97
|
+
const resolved = path.resolve(dirPath);
|
|
98
|
+
const parsed = path.parse(resolved);
|
|
99
|
+
const segments = resolved.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
100
|
+
const directoryFlag = fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY || 0);
|
|
101
|
+
let current = openNoFollow(parsed.root, directoryFlag, undefined);
|
|
102
|
+
try {
|
|
103
|
+
for (const segment of segments) {
|
|
104
|
+
const next = openRelativeNoFollow(current, segment, directoryFlag);
|
|
105
|
+
fs.closeSync(current);
|
|
106
|
+
current = next;
|
|
107
|
+
}
|
|
108
|
+
return current;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
fs.closeSync(current);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeFileAtomicBetweenDirectories(fileName, tempDirFd, targetDirFd, content, maxBytes = MAX_MAILDIR_FILE_BYTES) {
|
|
116
|
+
if (Buffer.byteLength(content, "utf8") > maxBytes) {
|
|
117
|
+
throw new Error("Message exceeds the Maildir size limit");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const tempName = `.amq-${process.pid}-${crypto.randomBytes(8).toString("hex")}.tmp`;
|
|
121
|
+
let fd;
|
|
122
|
+
try {
|
|
123
|
+
fd = openRelativeNoFollow(
|
|
124
|
+
tempDirFd,
|
|
125
|
+
tempName,
|
|
126
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
|
127
|
+
0o600
|
|
128
|
+
);
|
|
129
|
+
fs.writeFileSync(fd, content, "utf8");
|
|
130
|
+
fs.fsyncSync(fd);
|
|
131
|
+
fs.closeSync(fd);
|
|
132
|
+
fd = undefined;
|
|
133
|
+
fs.renameSync(
|
|
134
|
+
path.join(descriptorPath(tempDirFd), tempName),
|
|
135
|
+
path.join(descriptorPath(targetDirFd), fileName)
|
|
136
|
+
);
|
|
137
|
+
} finally {
|
|
138
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
139
|
+
try { fs.unlinkSync(path.join(descriptorPath(tempDirFd), tempName)); } catch {}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function writeBoundedFileAtomic(filePath, content, maxBytes = MAX_MAILDIR_FILE_BYTES) {
|
|
144
|
+
const dirFd = openDirectorySecure(path.dirname(filePath));
|
|
145
|
+
try {
|
|
146
|
+
writeFileAtomicBetweenDirectories(path.basename(filePath), dirFd, dirFd, content, maxBytes);
|
|
147
|
+
} finally {
|
|
148
|
+
fs.closeSync(dirFd);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function listMaildirMessageFiles(dirPath) {
|
|
153
|
+
const dirFd = openDirectorySecure(dirPath);
|
|
154
|
+
try {
|
|
155
|
+
return fs.readdirSync(descriptorPath(dirFd));
|
|
156
|
+
} finally {
|
|
157
|
+
fs.closeSync(dirFd);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function moveMaildirMessage(amqRoot, handle, fromFolder, toFolder, fileName) {
|
|
162
|
+
if (!isSafeMailIdentifier(handle, 128) || !isSafeMailIdentifier(fileName)) {
|
|
163
|
+
throw new Error("Invalid Maildir message path");
|
|
164
|
+
}
|
|
165
|
+
const agentDir = resolveAgentDirectory(amqRoot, handle, false);
|
|
166
|
+
if (!agentDir) return null;
|
|
167
|
+
const fromDir = ensureContainedDirectory(agentDir, ["inbox", fromFolder]);
|
|
168
|
+
const toDir = ensureContainedDirectory(agentDir, ["inbox", toFolder]);
|
|
169
|
+
const fromFd = openDirectorySecure(fromDir);
|
|
170
|
+
let toFd;
|
|
171
|
+
try {
|
|
172
|
+
toFd = openDirectorySecure(toDir);
|
|
173
|
+
fs.renameSync(
|
|
174
|
+
path.join(descriptorPath(fromFd), fileName),
|
|
175
|
+
path.join(descriptorPath(toFd), fileName)
|
|
176
|
+
);
|
|
177
|
+
return path.join(toDir, fileName);
|
|
178
|
+
} finally {
|
|
179
|
+
fs.closeSync(fromFd);
|
|
180
|
+
if (toFd !== undefined) fs.closeSync(toFd);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function readMaildirMessageFile(filePath, maxBytes = MAX_MAILDIR_FILE_BYTES) {
|
|
185
|
+
const dirFd = openDirectorySecure(path.dirname(filePath));
|
|
186
|
+
let fd;
|
|
187
|
+
try {
|
|
188
|
+
fd = openRelativeNoFollow(
|
|
189
|
+
dirFd,
|
|
190
|
+
path.basename(filePath),
|
|
191
|
+
fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0)
|
|
192
|
+
);
|
|
193
|
+
const stat = fs.fstatSync(fd);
|
|
194
|
+
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
195
|
+
|
|
196
|
+
const chunks = [];
|
|
197
|
+
let total = 0;
|
|
198
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
199
|
+
while (true) {
|
|
200
|
+
const bytes = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
201
|
+
if (bytes === 0) break;
|
|
202
|
+
total += bytes;
|
|
203
|
+
if (total > maxBytes) return null;
|
|
204
|
+
chunks.push(Buffer.from(buffer.subarray(0, bytes)));
|
|
205
|
+
}
|
|
206
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
207
|
+
} finally {
|
|
208
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
209
|
+
fs.closeSync(dirFd);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function ensureDirectoryExists(dir) {
|
|
214
|
+
try {
|
|
215
|
+
fs.mkdirSync(dir);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error.code !== "EEXIST") throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function ensureContainedDirectory(rootDir, segments) {
|
|
222
|
+
let current = rootDir;
|
|
223
|
+
for (const segment of segments) {
|
|
224
|
+
current = path.join(current, segment);
|
|
225
|
+
if (!fs.existsSync(current)) ensureDirectoryExists(current);
|
|
226
|
+
const stat = fs.lstatSync(current);
|
|
227
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Invalid AMQ mailbox directory");
|
|
228
|
+
const real = fs.realpathSync(current);
|
|
229
|
+
if (real !== current || !real.startsWith(`${rootDir}${path.sep}`)) {
|
|
230
|
+
throw new Error("AMQ mailbox directory escaped the queue root");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return current;
|
|
234
|
+
}
|
|
235
|
+
|
|
41
236
|
/**
|
|
42
237
|
* Determine a canonical p2p or group thread ID.
|
|
43
238
|
* For 2 participants, sorts lexicographically: p2p/<agentA>__<agentB>
|
|
@@ -140,20 +335,15 @@ export function parseMessage(content = "") {
|
|
|
140
335
|
* - outbox/sent
|
|
141
336
|
*/
|
|
142
337
|
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
|
-
|
|
338
|
+
const agentDir = resolveAgentDirectory(amqRoot, handle, true);
|
|
339
|
+
ensureContainedDirectory(agentDir, ["inbox"]);
|
|
340
|
+
ensureContainedDirectory(agentDir, ["inbox", "tmp"]);
|
|
341
|
+
ensureContainedDirectory(agentDir, ["inbox", "new"]);
|
|
342
|
+
ensureContainedDirectory(agentDir, ["inbox", "cur"]);
|
|
343
|
+
ensureContainedDirectory(agentDir, ["outbox"]);
|
|
344
|
+
ensureContainedDirectory(agentDir, ["outbox", "tmp"]);
|
|
345
|
+
ensureContainedDirectory(agentDir, ["outbox", "sent"]);
|
|
346
|
+
ensureContainedDirectory(agentDir, ["receipts"]);
|
|
157
347
|
return agentDir;
|
|
158
348
|
}
|
|
159
349
|
|
|
@@ -169,10 +359,15 @@ export function sendMaildirMessage(amqRoot, options = {}) {
|
|
|
169
359
|
const { from, to, subject, body, priority, kind, thread, refs, labels, context, attachments = [] } = options;
|
|
170
360
|
|
|
171
361
|
if (!from) throw new Error("Sender 'from' is required");
|
|
362
|
+
if (!isSafeMailIdentifier(from, 128)) throw new Error("Invalid sender handle");
|
|
172
363
|
const recipients = Array.isArray(to) ? to : (to ? [to] : []);
|
|
173
364
|
if (!recipients.length) throw new Error("At least one recipient in 'to' is required");
|
|
365
|
+
if (!recipients.every((recipient) => isSafeMailIdentifier(recipient, 128))) {
|
|
366
|
+
throw new Error("Invalid recipient handle");
|
|
367
|
+
}
|
|
174
368
|
|
|
175
369
|
const msgId = options.id || generateMessageId();
|
|
370
|
+
if (!isSafeMailIdentifier(msgId)) throw new Error("Invalid message id");
|
|
176
371
|
const created = options.created || new Date().toISOString();
|
|
177
372
|
|
|
178
373
|
// Process attachments: auto-freeze ephemeral files into CAS blobstore if needed
|
|
@@ -203,21 +398,29 @@ export function sendMaildirMessage(amqRoot, options = {}) {
|
|
|
203
398
|
created,
|
|
204
399
|
});
|
|
205
400
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
fs.writeFileSync(path.join(senderSentDir, `${msgId}.md`), fileText, "utf8");
|
|
401
|
+
if (Buffer.byteLength(fileText, "utf8") > MAX_MAILDIR_FILE_BYTES) {
|
|
402
|
+
throw new Error("Message exceeds the Maildir size limit");
|
|
403
|
+
}
|
|
210
404
|
|
|
211
|
-
// Deliver to each recipient using atomic Maildir tmp -> new rename
|
|
212
405
|
for (const recipient of recipients) {
|
|
213
|
-
ensureAgentMailbox(amqRoot, recipient);
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
406
|
+
const recipientDir = ensureAgentMailbox(amqRoot, recipient);
|
|
407
|
+
const tmpFd = openDirectorySecure(path.join(recipientDir, "inbox", "tmp"));
|
|
408
|
+
let newFd;
|
|
409
|
+
try {
|
|
410
|
+
newFd = openDirectorySecure(path.join(recipientDir, "inbox", "new"));
|
|
411
|
+
writeFileAtomicBetweenDirectories(`${msgId}.md`, tmpFd, newFd, fileText);
|
|
412
|
+
} finally {
|
|
413
|
+
fs.closeSync(tmpFd);
|
|
414
|
+
if (newFd !== undefined) fs.closeSync(newFd);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const senderDir = ensureAgentMailbox(amqRoot, from);
|
|
419
|
+
const sentFd = openDirectorySecure(path.join(senderDir, "outbox", "sent"));
|
|
420
|
+
try {
|
|
421
|
+
writeFileAtomicBetweenDirectories(`${msgId}.md`, sentFd, sentFd, fileText);
|
|
422
|
+
} finally {
|
|
423
|
+
fs.closeSync(sentFd);
|
|
221
424
|
}
|
|
222
425
|
|
|
223
426
|
return {
|
|
@@ -236,35 +439,85 @@ export function sendMaildirMessage(amqRoot, options = {}) {
|
|
|
236
439
|
/**
|
|
237
440
|
* Locate any message across all agent maildirs by ID.
|
|
238
441
|
*/
|
|
239
|
-
export function
|
|
240
|
-
if (!amqRoot || !msgId)
|
|
241
|
-
|
|
242
|
-
|
|
442
|
+
export function markMaildirMessageRead(amqRoot, handle, msgId) {
|
|
443
|
+
if (!amqRoot || !isSafeMailIdentifier(handle, 128) || !isSafeMailIdentifier(msgId)) {
|
|
444
|
+
return { ok: false, error: "Invalid mailbox or message identifier" };
|
|
445
|
+
}
|
|
243
446
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
447
|
+
try {
|
|
448
|
+
const agentDir = resolveAgentDirectory(amqRoot, handle, false);
|
|
449
|
+
if (!agentDir) return { ok: false, error: "Mailbox not found" };
|
|
450
|
+
const newPath = path.join(agentDir, "inbox", "new", `${msgId}.md`);
|
|
451
|
+
const curPath = path.join(agentDir, "inbox", "cur", `${msgId}.md`);
|
|
452
|
+
if (!fs.existsSync(newPath)) {
|
|
453
|
+
if (fs.existsSync(curPath)) return { ok: true, alreadyRead: true, id: msgId, filePath: curPath };
|
|
454
|
+
return { ok: false, error: "Message not found" };
|
|
455
|
+
}
|
|
251
456
|
|
|
252
|
-
|
|
457
|
+
const content = readMaildirMessageFile(newPath);
|
|
458
|
+
if (content === null) return { ok: false, error: "Message could not be read" };
|
|
459
|
+
const { header } = parseMessage(content);
|
|
460
|
+
const recipients = Array.isArray(header.to) ? header.to : [header.to];
|
|
461
|
+
if (header.id !== msgId || !recipients.includes(handle)) {
|
|
462
|
+
return { ok: false, error: "Message is not addressed to this mailbox" };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const filePath = moveMaildirMessage(amqRoot, handle, "new", "cur", `${msgId}.md`);
|
|
466
|
+
if (!filePath) return { ok: false, error: "Message could not be marked read" };
|
|
467
|
+
return { ok: true, alreadyRead: false, id: msgId, filePath };
|
|
468
|
+
} catch (error) {
|
|
469
|
+
return { ok: false, error: error.message };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function findMessageForRecipient(amqRoot, recipient, msgId) {
|
|
474
|
+
if (!amqRoot || !isSafeMailIdentifier(recipient, 128) || !isSafeMailIdentifier(msgId)) return null;
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
const agentDir = resolveAgentDirectory(amqRoot, recipient, false);
|
|
478
|
+
if (!agentDir) return null;
|
|
479
|
+
|
|
480
|
+
for (const dir of [path.join(agentDir, "inbox", "new"), path.join(agentDir, "inbox", "cur")]) {
|
|
253
481
|
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 {}
|
|
482
|
+
if (!fs.existsSync(filePath)) continue;
|
|
483
|
+
const content = readMaildirMessageFile(filePath);
|
|
484
|
+
if (content === null) continue;
|
|
485
|
+
const { header, body } = parseMessage(content);
|
|
486
|
+
const recipients = Array.isArray(header.to) ? header.to : [header.to];
|
|
487
|
+
if (header.id === msgId && recipients.includes(recipient)) {
|
|
488
|
+
return { id: msgId, header, body, filePath, foundIn: recipient };
|
|
266
489
|
}
|
|
267
490
|
}
|
|
491
|
+
} catch {}
|
|
492
|
+
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function findMessageById(amqRoot, msgId) {
|
|
497
|
+
if (!amqRoot || !isSafeMailIdentifier(msgId)) return null;
|
|
498
|
+
const agentsDir = path.join(amqRoot, "agents");
|
|
499
|
+
if (!fs.existsSync(agentsDir)) return null;
|
|
500
|
+
|
|
501
|
+
for (const agent of fs.readdirSync(agentsDir)) {
|
|
502
|
+
if (!isSafeMailIdentifier(agent, 128)) continue;
|
|
503
|
+
try {
|
|
504
|
+
const agentDir = resolveAgentDirectory(amqRoot, agent, false);
|
|
505
|
+
if (!agentDir) continue;
|
|
506
|
+
const candidateDirs = [
|
|
507
|
+
path.join(agentDir, "inbox", "new"),
|
|
508
|
+
path.join(agentDir, "inbox", "cur"),
|
|
509
|
+
path.join(agentDir, "outbox", "sent"),
|
|
510
|
+
];
|
|
511
|
+
|
|
512
|
+
for (const dir of candidateDirs) {
|
|
513
|
+
const filePath = path.join(dir, `${msgId}.md`);
|
|
514
|
+
if (!fs.existsSync(filePath)) continue;
|
|
515
|
+
const content = readMaildirMessageFile(filePath);
|
|
516
|
+
if (content === null) continue;
|
|
517
|
+
const { header, body } = parseMessage(content);
|
|
518
|
+
if (header.id === msgId) return { id: msgId, header, body, filePath, foundIn: agent };
|
|
519
|
+
}
|
|
520
|
+
} catch {}
|
|
268
521
|
}
|
|
269
522
|
|
|
270
523
|
return null;
|
|
@@ -316,29 +569,24 @@ export function replyMaildirMessage(amqRoot, options = {}) {
|
|
|
316
569
|
* Drain all new messages for an agent (atomic Maildir new/ -> cur/ transition).
|
|
317
570
|
*/
|
|
318
571
|
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"));
|
|
572
|
+
if (!amqRoot || !isSafeMailIdentifier(handle, 128)) return [];
|
|
573
|
+
const agentDir = resolveAgentDirectory(amqRoot, handle, true);
|
|
574
|
+
const newDir = ensureContainedDirectory(agentDir, ["inbox", "new"]);
|
|
575
|
+
const files = listMaildirMessageFiles(newDir).filter((file) => file.endsWith(".md"));
|
|
327
576
|
const drained = [];
|
|
328
577
|
|
|
329
|
-
for (const
|
|
330
|
-
const fromPath = path.join(newDir, f);
|
|
331
|
-
const toPath = path.join(curDir, f);
|
|
578
|
+
for (const file of files) {
|
|
332
579
|
try {
|
|
333
|
-
const content =
|
|
580
|
+
const content = readMaildirMessageFile(path.join(newDir, file));
|
|
581
|
+
if (content === null) continue;
|
|
334
582
|
const { header, body } = parseMessage(content);
|
|
335
|
-
|
|
336
|
-
|
|
583
|
+
const filePath = moveMaildirMessage(amqRoot, handle, "new", "cur", file);
|
|
584
|
+
if (!filePath) continue;
|
|
337
585
|
drained.push({
|
|
338
|
-
id:
|
|
586
|
+
id: file.replace(/\.md$/, ""),
|
|
339
587
|
header,
|
|
340
588
|
body,
|
|
341
|
-
filePath
|
|
589
|
+
filePath,
|
|
342
590
|
});
|
|
343
591
|
} catch {}
|
|
344
592
|
}
|
|
@@ -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
|
+
}
|