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/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 = 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
-
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
- // 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");
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 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);
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 findMessageById(amqRoot, msgId) {
240
- if (!amqRoot || !msgId) return null;
241
- const agentsDir = path.join(amqRoot, "agents");
242
- if (!fs.existsSync(agentsDir)) return null;
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
- 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
- ];
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
- for (const dir of candidateDirs) {
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
- 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 {}
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 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"));
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 f of files) {
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 = fs.readFileSync(fromPath, "utf8");
580
+ const content = readMaildirMessageFile(path.join(newDir, file));
581
+ if (content === null) continue;
334
582
  const { header, body } = parseMessage(content);
335
- // Atomic move to cur
336
- fs.renameSync(fromPath, toPath);
583
+ const filePath = moveMaildirMessage(amqRoot, handle, "new", "cur", file);
584
+ if (!filePath) continue;
337
585
  drained.push({
338
- id: f.replace(/\.md$/, ""),
586
+ id: file.replace(/\.md$/, ""),
339
587
  header,
340
588
  body,
341
- filePath: toPath,
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
+ }