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/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 = 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
-
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
- // 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");
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 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);
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 findMessageById(amqRoot, msgId) {
240
- if (!amqRoot || !msgId) return null;
241
- const agentsDir = path.join(amqRoot, "agents");
242
- if (!fs.existsSync(agentsDir)) return null;
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
- 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
- ];
508
+ try {
509
+ const agentDir = resolveAgentDirectory(amqRoot, recipient, false);
510
+ if (!agentDir) return null;
251
511
 
252
- for (const dir of candidateDirs) {
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
- 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 {}
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 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"));
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 f of files) {
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 = fs.readFileSync(fromPath, "utf8");
612
+ const content = readMaildirMessageFile(path.join(newDir, file));
613
+ if (content === null) continue;
334
614
  const { header, body } = parseMessage(content);
335
- // Atomic move to cur
336
- fs.renameSync(fromPath, toPath);
615
+ const filePath = moveMaildirMessage(amqRoot, handle, "new", "cur", file);
616
+ if (!filePath) continue;
337
617
  drained.push({
338
- id: f.replace(/\.md$/, ""),
618
+ id: file.replace(/\.md$/, ""),
339
619
  header,
340
620
  body,
341
- filePath: toPath,
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
+ }