herdr-plugin-amq 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bridge.mjs ADDED
@@ -0,0 +1,373 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync, spawn } from "node:child_process";
4
+ import {
5
+ getHerdrBin,
6
+ getStateDir,
7
+ getConfigDir,
8
+ findAmqRoot,
9
+ getAgentHandles,
10
+ execCmd,
11
+ } from "./config.mjs";
12
+
13
+ function getPidFile() {
14
+ return path.join(getStateDir(), "bridge.pid");
15
+ }
16
+
17
+ function getStateFile() {
18
+ return path.join(getStateDir(), "bridge-state.json");
19
+ }
20
+
21
+ function getAlertLogFile() {
22
+ return path.join(getStateDir(), "alerts.log");
23
+ }
24
+
25
+ export function isDaemonRunning() {
26
+ const pidFile = getPidFile();
27
+ if (!fs.existsSync(pidFile)) return null;
28
+
29
+ try {
30
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
31
+ if (!Number.isInteger(pid) || pid <= 0) {
32
+ try { fs.unlinkSync(pidFile); } catch {}
33
+ return null;
34
+ }
35
+ // Check if process exists
36
+ process.kill(pid, 0);
37
+ return pid;
38
+ } catch {
39
+ try { fs.unlinkSync(pidFile); } catch {}
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export function stopDaemon() {
45
+ const pid = isDaemonRunning();
46
+ if (!pid) {
47
+ const pidFile = getPidFile();
48
+ if (fs.existsSync(pidFile)) {
49
+ try { fs.unlinkSync(pidFile); } catch {}
50
+ }
51
+ return { ok: true, message: "No active bridge daemon running." };
52
+ }
53
+
54
+ try {
55
+ process.kill(pid, "SIGTERM");
56
+ const pidFile = getPidFile();
57
+ try { fs.unlinkSync(pidFile); } catch {}
58
+ return { ok: true, pid, message: `Stopped bridge daemon (PID ${pid}).` };
59
+ } catch (err) {
60
+ return { ok: false, pid, error: err.message };
61
+ }
62
+ }
63
+
64
+ export function startDaemonBackground() {
65
+ const existingPid = isDaemonRunning();
66
+ if (existingPid) {
67
+ return { ok: true, pid: existingPid, alreadyRunning: true };
68
+ }
69
+
70
+ const scriptPath = path.resolve(import.meta.dirname, "../bin/herdr-amq.mjs");
71
+ const child = spawn(process.execPath, [scriptPath, "bridge-daemon"], {
72
+ detached: true,
73
+ stdio: "ignore",
74
+ env: { ...process.env },
75
+ });
76
+
77
+ child.unref();
78
+
79
+ const pid = child.pid;
80
+ fs.writeFileSync(getPidFile(), String(pid), "utf8");
81
+ return { ok: true, pid, alreadyRunning: false };
82
+ }
83
+
84
+ // ─── Internal Bridge Operations ───────────────────────────────────────────────
85
+
86
+ function runHerdr(args) {
87
+ const bin = getHerdrBin();
88
+ return execCmd(bin, args);
89
+ }
90
+
91
+ function getAgentStatus(handle) {
92
+ try {
93
+ const out = runHerdr(["agent", "get", handle]);
94
+ const j = JSON.parse(out);
95
+ return j?.result?.agent?.agent_status ?? "unknown";
96
+ } catch {
97
+ return "missing";
98
+ }
99
+ }
100
+
101
+ function healAgentName(handle, dryRun = false) {
102
+ try {
103
+ const out = runHerdr(["pane", "list"]);
104
+ const panes = JSON.parse(out)?.result?.panes ?? [];
105
+ const needle = `- ${handle} - `;
106
+ const hit = panes.find((p) =>
107
+ (p.terminal_title_stripped || p.terminal_title || "").includes(needle)
108
+ );
109
+ if (!hit) return false;
110
+ if (dryRun) {
111
+ console.log(`[bridge] DRY: would heal name ${handle} <- pane ${hit.pane_id}`);
112
+ return true;
113
+ }
114
+ runHerdr(["agent", "rename", hit.pane_id, handle]);
115
+ console.log(`[bridge] Healed pane ${hit.pane_id} -> renamed back to '${handle}'`);
116
+ return true;
117
+ } catch (err) {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ function promptAgent(handle, text, dryRun = false) {
123
+ if (dryRun) {
124
+ console.log(`[bridge] DRY: would prompt ${handle}: ${text.slice(0, 60)}...`);
125
+ return true;
126
+ }
127
+ try {
128
+ runHerdr(["agent", "prompt", handle, text]);
129
+ return true;
130
+ } catch (err) {
131
+ console.warn(`[bridge] Warning: prompt ${handle} failed: ${err.message}`);
132
+ return false;
133
+ }
134
+ }
135
+
136
+ function recordAlert(handle, count, from, dryRun = false) {
137
+ const timestamp = new Date().toISOString();
138
+ const line =
139
+ `${timestamp} BLOCKED: agent ${handle} has ${count} unread AMQ message(s)` +
140
+ (from ? ` (latest from ${from})` : "") +
141
+ `. A blocked agent requires human intervention. Inspect: herdr agent read ${handle} --lines 40`;
142
+
143
+ console.log(`[bridge] ${line}`);
144
+ if (dryRun) return;
145
+
146
+ try {
147
+ const alertFile = getAlertLogFile();
148
+ fs.appendFileSync(alertFile, line + "\n");
149
+ } catch {}
150
+ }
151
+
152
+ function loadDeliveredState() {
153
+ const stateFile = getStateFile();
154
+ try {
155
+ return JSON.parse(fs.readFileSync(stateFile, "utf8"));
156
+ } catch {
157
+ return { delivered: {} };
158
+ }
159
+ }
160
+
161
+ function saveDeliveredState(state) {
162
+ const stateFile = getStateFile();
163
+ const ids = Object.keys(state.delivered || {});
164
+ if (ids.length > 2000) {
165
+ ids.sort();
166
+ for (const id of ids.slice(0, ids.length - 1500)) {
167
+ delete state.delivered[id];
168
+ }
169
+ }
170
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 1), "utf8");
171
+ }
172
+
173
+ export function listInbox(amqRoot, handle) {
174
+ try {
175
+ const out = execCmd("amq", [
176
+ "list",
177
+ "--root",
178
+ amqRoot,
179
+ "--me",
180
+ handle,
181
+ "--new",
182
+ "--json",
183
+ ]);
184
+ const parsed = JSON.parse(out);
185
+ if (Array.isArray(parsed)) return parsed;
186
+ } catch {
187
+ // Fall through to native pure-JS Maildir reader
188
+ }
189
+
190
+ try {
191
+ const newDir = path.join(amqRoot, "agents", handle, "inbox", "new");
192
+ if (!fs.existsSync(newDir)) return [];
193
+ const files = fs.readdirSync(newDir).filter((f) => !f.startsWith("."));
194
+ const msgs = [];
195
+ for (const f of files) {
196
+ const fullPath = path.join(newDir, f);
197
+ const content = fs.readFileSync(fullPath, "utf8");
198
+ const jsonMatch = content.match(/^---json\r?\n([\s\S]*?)\r?\n---/);
199
+ if (jsonMatch) {
200
+ try {
201
+ const header = JSON.parse(jsonMatch[1]);
202
+ msgs.push({
203
+ id: header.id || f,
204
+ from: header.from,
205
+ to: header.to,
206
+ subject: header.subject,
207
+ thread: header.thread,
208
+ created: header.created,
209
+ });
210
+ continue;
211
+ } catch {}
212
+ }
213
+ const yamlMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
214
+ if (yamlMatch) {
215
+ const header = {};
216
+ for (const line of yamlMatch[1].split("\n")) {
217
+ const colon = line.indexOf(":");
218
+ if (colon !== -1) {
219
+ const k = line.slice(0, colon).trim();
220
+ const v = line.slice(colon + 1).trim();
221
+ header[k] = v;
222
+ }
223
+ }
224
+ msgs.push({
225
+ id: header.id || f,
226
+ from: header.from,
227
+ to: header.to ? [header.to] : [],
228
+ subject: header.subject,
229
+ thread: header.thread,
230
+ created: header.created,
231
+ });
232
+ continue;
233
+ }
234
+ msgs.push({ id: f, from: "unknown", subject: "(raw mail)" });
235
+ }
236
+ return msgs;
237
+ } catch {
238
+ return [];
239
+ }
240
+ }
241
+
242
+ const buildDoorbellPrompt = (handle, msgs) => {
243
+ const senders = [...new Set(msgs.map((m) => m.from))].join(", ");
244
+ const n = msgs.length;
245
+ return (
246
+ `AMQ doorbell: ${n} new message(s) in your inbox (from ${senders}). ` +
247
+ `Run: amq drain --me ${handle} --include-body, then reply to the sender on the same ` +
248
+ `thread with amq reply --id <msg_id>. After replying, resume your work.`
249
+ );
250
+ };
251
+
252
+ // ─── Doorbell Pass ────────────────────────────────────────────────────────────
253
+
254
+ export function runDoorbellPass({
255
+ amqRoot = findAmqRoot(),
256
+ handles = null,
257
+ targetHandle = null,
258
+ dryRun = false,
259
+ } = {}) {
260
+ if (!amqRoot) {
261
+ return { ok: false, error: "No .agent-mail queue found." };
262
+ }
263
+
264
+ const agentList = targetHandle
265
+ ? [targetHandle]
266
+ : (handles && handles.length > 0 ? handles : getAgentHandles(amqRoot));
267
+
268
+ if (!agentList.length) {
269
+ return { ok: true, checked: 0, doorbelled: 0, message: "No registered agents found." };
270
+ }
271
+
272
+ const state = loadDeliveredState();
273
+ state.delivered = state.delivered || {};
274
+
275
+ let doorbelledCount = 0;
276
+ const results = [];
277
+
278
+ for (const handle of agentList) {
279
+ const rawMsgs = listInbox(amqRoot, handle);
280
+ // Ignore self-messages
281
+ const msgs = rawMsgs.filter((m) => m.from !== handle);
282
+
283
+ if (!msgs.length) continue;
284
+
285
+ // Filter messages not yet delivered
286
+ const undelivered = msgs.filter((m) => !state.delivered[m.id]);
287
+ if (!undelivered.length) continue;
288
+
289
+ let status = getAgentStatus(handle);
290
+ if (status === "missing") {
291
+ const healed = healAgentName(handle, dryRun);
292
+ if (healed) status = getAgentStatus(handle);
293
+ }
294
+
295
+ if (status === "idle" || status === "done") {
296
+ const text = buildDoorbellPrompt(handle, undelivered);
297
+ const ok = promptAgent(handle, text, dryRun);
298
+ if (ok) {
299
+ doorbelledCount += undelivered.length;
300
+ if (!dryRun) {
301
+ for (const m of undelivered) {
302
+ state.delivered[m.id] = {
303
+ at: new Date().toISOString(),
304
+ to: handle,
305
+ from: m.from,
306
+ };
307
+ }
308
+ }
309
+ results.push({ handle, status, count: undelivered.length, action: "prompted" });
310
+ }
311
+ } else if (status === "working") {
312
+ results.push({ handle, status, count: undelivered.length, action: "working_wait" });
313
+ } else if (status === "blocked") {
314
+ recordAlert(handle, undelivered.length, undelivered[0]?.from, dryRun);
315
+ results.push({ handle, status, count: undelivered.length, action: "alert_blocked" });
316
+ } else {
317
+ results.push({ handle, status, count: undelivered.length, action: "unknown_state" });
318
+ }
319
+ }
320
+
321
+ if (!dryRun && doorbelledCount > 0) {
322
+ saveDeliveredState(state);
323
+ }
324
+
325
+ return {
326
+ ok: true,
327
+ amqRoot,
328
+ agentsChecked: agentList.length,
329
+ doorbelled: doorbelledCount,
330
+ results,
331
+ };
332
+ }
333
+
334
+ // ─── Continuous Daemon Loop ───────────────────────────────────────────────────
335
+
336
+ export function startDaemonLoop({ interval = 3000, dryRun = false } = {}) {
337
+ const amqRoot = findAmqRoot();
338
+ if (!amqRoot) {
339
+ console.error("[bridge] Fatal: could not locate .agent-mail queue directory.");
340
+ process.exit(1);
341
+ }
342
+
343
+ const pid = process.pid;
344
+ fs.writeFileSync(getPidFile(), String(pid), "utf8");
345
+
346
+ const cleanup = () => {
347
+ console.log(`[bridge] Stopping bridge daemon (PID ${pid})...`);
348
+ try { fs.unlinkSync(getPidFile()); } catch {}
349
+ process.exit(0);
350
+ };
351
+
352
+ process.on("SIGINT", cleanup);
353
+ process.on("SIGTERM", cleanup);
354
+
355
+ console.log(`[bridge] AMQ Herdr Bridge started (PID ${pid})`);
356
+ console.log(`[bridge] Queue: ${amqRoot}`);
357
+ console.log(`[bridge] Interval: ${interval}ms`);
358
+
359
+ const tick = () => {
360
+ try {
361
+ const handles = getAgentHandles(amqRoot);
362
+ const res = runDoorbellPass({ amqRoot, handles, dryRun });
363
+ if (res.doorbelled > 0) {
364
+ console.log(`[bridge] Doorbelled ${res.doorbelled} message(s)`);
365
+ }
366
+ } catch (err) {
367
+ console.error(`[bridge] Error in pass: ${err.message}`);
368
+ }
369
+ };
370
+
371
+ tick();
372
+ setInterval(tick, interval);
373
+ }
package/src/briefs.mjs ADDED
@@ -0,0 +1,201 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { formatAgentTitle } from "./store.mjs";
4
+
5
+ const CANDIDATE_BRIEF_DIRS = [
6
+ path.join(".opencode", "agents"),
7
+ ".agents",
8
+ path.join(".pi", "agents"),
9
+ "agents",
10
+ path.join(".claude", "agents"),
11
+ path.join(".gemini", "agents"),
12
+ ];
13
+
14
+ /**
15
+ * Parse an agent definition file (Markdown with YAML frontmatter or JSON)
16
+ */
17
+ export function parseAgentBriefFile(filePath, repoRoot = "") {
18
+ try {
19
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
20
+ return null;
21
+ }
22
+
23
+ const ext = path.extname(filePath).toLowerCase();
24
+ const raw = fs.readFileSync(filePath, "utf8");
25
+ const baseName = path.basename(filePath, ext);
26
+ const relPath = repoRoot ? path.relative(repoRoot, filePath) : filePath;
27
+
28
+ if (ext === ".json") {
29
+ const obj = JSON.parse(raw);
30
+ const handle = obj.handle || baseName;
31
+ return {
32
+ handle,
33
+ name: obj.name || formatAgentTitle(handle),
34
+ description: obj.description || "",
35
+ role: obj.role || obj.description || formatAgentTitle(handle),
36
+ model: obj.model || null,
37
+ mode: obj.mode || "subagent",
38
+ prompt: (obj.prompt || obj.systemPrompt || obj.brief || "").trim(),
39
+ source: relPath,
40
+ fullPath: filePath,
41
+ };
42
+ }
43
+
44
+ // Markdown with YAML frontmatter: --- ... --- body
45
+ const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
46
+ const frontmatter = {};
47
+ let prompt = raw.trim();
48
+
49
+ if (match) {
50
+ prompt = (match[2] || "").trim();
51
+ const yamlContent = match[1];
52
+
53
+ for (const line of yamlContent.split("\n")) {
54
+ const trimmed = line.trim();
55
+ if (!trimmed || trimmed.startsWith("#")) continue;
56
+ const colonIdx = trimmed.indexOf(":");
57
+ if (colonIdx !== -1) {
58
+ const key = trimmed.slice(0, colonIdx).trim();
59
+ let val = trimmed.slice(colonIdx + 1).trim();
60
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
61
+ val = val.slice(1, -1);
62
+ }
63
+ frontmatter[key] = val;
64
+ }
65
+ }
66
+ }
67
+
68
+ const handle = frontmatter.handle || baseName;
69
+ const name = frontmatter.name || formatAgentTitle(handle);
70
+ const description = frontmatter.description || "";
71
+ const role = frontmatter.role || description || formatAgentTitle(handle);
72
+ const model = frontmatter.model || null;
73
+ const mode = frontmatter.mode || "subagent";
74
+
75
+ return {
76
+ handle,
77
+ name,
78
+ description,
79
+ role,
80
+ model,
81
+ mode,
82
+ prompt,
83
+ source: relPath,
84
+ fullPath: filePath,
85
+ };
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Scan all standard brief directories in repository
93
+ * Searches: .opencode/agents, .agents, .pi/agents, agents, etc.
94
+ */
95
+ export function scanAgentBriefs(repoRoot) {
96
+ const briefs = new Map();
97
+ if (!repoRoot || !fs.existsSync(repoRoot)) {
98
+ return briefs;
99
+ }
100
+
101
+ for (const relDir of CANDIDATE_BRIEF_DIRS) {
102
+ const dirPath = path.join(repoRoot, relDir);
103
+ if (!fs.existsSync(dirPath)) continue;
104
+
105
+ try {
106
+ const files = fs.readdirSync(dirPath);
107
+ for (const f of files) {
108
+ const ext = path.extname(f).toLowerCase();
109
+ if (ext !== ".md" && ext !== ".json" && ext !== ".yaml" && ext !== ".yml") {
110
+ continue;
111
+ }
112
+ const fullPath = path.join(dirPath, f);
113
+ const parsed = parseAgentBriefFile(fullPath, repoRoot);
114
+ if (parsed && parsed.handle && !briefs.has(parsed.handle)) {
115
+ briefs.set(parsed.handle, parsed);
116
+ }
117
+ }
118
+ } catch {}
119
+ }
120
+
121
+ return briefs;
122
+ }
123
+
124
+ /**
125
+ * Get brief for a single agent handle across candidate directories
126
+ */
127
+ export function getAgentBrief(repoRoot, handle) {
128
+ if (!handle || !repoRoot) return null;
129
+ const safeHandle = handle.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
130
+
131
+ // Check known candidate directories in order
132
+ for (const relDir of CANDIDATE_BRIEF_DIRS) {
133
+ const dirPath = path.join(repoRoot, relDir);
134
+ if (!fs.existsSync(dirPath)) continue;
135
+
136
+ const candidates = [
137
+ path.join(dirPath, `${safeHandle}.md`),
138
+ path.join(dirPath, `${safeHandle}.json`),
139
+ path.join(dirPath, `${safeHandle}.yaml`),
140
+ path.join(dirPath, `${safeHandle}.yml`),
141
+ path.join(dirPath, safeHandle, "brief.md"),
142
+ path.join(dirPath, safeHandle, "prompt.md"),
143
+ ];
144
+
145
+ for (const fileCandidate of candidates) {
146
+ if (fs.existsSync(fileCandidate)) {
147
+ const parsed = parseAgentBriefFile(fileCandidate, repoRoot);
148
+ if (parsed) return parsed;
149
+ }
150
+ }
151
+ }
152
+
153
+ return null;
154
+ }
155
+
156
+ /**
157
+ * Save or update agent brief file on disk
158
+ */
159
+ export function saveAgentBrief(repoRoot, handle, { description = "", prompt = "", model = null, role = "" }) {
160
+ if (!handle || !repoRoot || !fs.existsSync(repoRoot)) {
161
+ return { ok: false, error: "Invalid repository root or handle" };
162
+ }
163
+
164
+ const safeHandle = handle.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
165
+ let targetDir = path.join(repoRoot, ".opencode", "agents");
166
+ if (!fs.existsSync(targetDir)) {
167
+ if (fs.existsSync(path.join(repoRoot, ".agents"))) {
168
+ targetDir = path.join(repoRoot, ".agents");
169
+ } else if (fs.existsSync(path.join(repoRoot, ".pi", "agents"))) {
170
+ targetDir = path.join(repoRoot, ".pi", "agents");
171
+ } else {
172
+ fs.mkdirSync(targetDir, { recursive: true });
173
+ }
174
+ }
175
+
176
+ const targetFile = path.join(targetDir, `${safeHandle}.md`);
177
+ const effectiveDesc = description || role || `Specialist agent for ${safeHandle}`;
178
+ const frontmatterLines = [
179
+ "---",
180
+ `description: ${effectiveDesc}`,
181
+ "mode: subagent",
182
+ ];
183
+ if (model) {
184
+ frontmatterLines.push(`model: ${model}`);
185
+ }
186
+ frontmatterLines.push("---");
187
+ frontmatterLines.push("");
188
+ frontmatterLines.push(prompt.trim() || `You are the ${safeHandle} agent.`);
189
+ frontmatterLines.push("");
190
+
191
+ try {
192
+ fs.writeFileSync(targetFile, frontmatterLines.join("\n"), "utf8");
193
+ return {
194
+ ok: true,
195
+ path: targetFile,
196
+ relPath: path.relative(repoRoot, targetFile),
197
+ };
198
+ } catch (err) {
199
+ return { ok: false, error: err.message };
200
+ }
201
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,146 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+
5
+ export function getHerdrBin() {
6
+ return process.env.HERDR_BIN_PATH || "herdr";
7
+ }
8
+
9
+ export function getStateDir() {
10
+ const dir = process.env.HERDR_PLUGIN_STATE_DIR || path.join(process.env.HOME || "/tmp", ".herdr-amq-state");
11
+ if (!fs.existsSync(dir)) {
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ }
14
+ return dir;
15
+ }
16
+
17
+ export function getRepoRootFromAmq(amqRoot) {
18
+ if (!amqRoot) return process.cwd();
19
+ return path.basename(amqRoot) === ".agent-mail"
20
+ ? path.resolve(path.dirname(amqRoot))
21
+ : path.resolve(amqRoot);
22
+ }
23
+
24
+ export function getConfigDir() {
25
+ const dir = process.env.HERDR_PLUGIN_CONFIG_DIR || path.join(process.env.HOME || "/tmp", ".herdr-amq-config");
26
+ if (!fs.existsSync(dir)) {
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ }
29
+ return dir;
30
+ }
31
+
32
+ export function getContext() {
33
+ const raw = process.env.HERDR_PLUGIN_CONTEXT_JSON;
34
+ if (!raw) return null;
35
+ try {
36
+ return JSON.parse(raw);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function getEventContext() {
43
+ const raw = process.env.HERDR_PLUGIN_EVENT_JSON;
44
+ if (!raw) return null;
45
+ try {
46
+ return JSON.parse(raw);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Resolve the active AMQ queue root (.agent-mail)
54
+ */
55
+ export function findAmqRoot(cwd = process.cwd()) {
56
+ // 1. Explicit env var
57
+ if (process.env.AM_ROOT && fs.existsSync(process.env.AM_ROOT)) {
58
+ return path.resolve(process.env.AM_ROOT);
59
+ }
60
+
61
+ // 2. Context from Herdr workspace / worktree
62
+ const ctx = getContext();
63
+ if (ctx) {
64
+ const candidates = [
65
+ ctx.worktree?.checkout_path,
66
+ ctx.worktree?.repo_root,
67
+ ctx.workspace?.tokens?.cwd,
68
+ ctx.cwd,
69
+ ].filter(Boolean);
70
+
71
+ for (const dir of candidates) {
72
+ const mailDir = path.join(dir, ".agent-mail");
73
+ if (fs.existsSync(mailDir)) return mailDir;
74
+ }
75
+ }
76
+
77
+ // 3. Search up from current directory
78
+ let curr = path.resolve(cwd);
79
+ while (curr !== path.dirname(curr)) {
80
+ const mailDir = path.join(curr, ".agent-mail");
81
+ if (fs.existsSync(mailDir)) return mailDir;
82
+ curr = path.dirname(curr);
83
+ }
84
+
85
+ // 4. Fallback in process.cwd()
86
+ const cwdMailDir = path.join(process.cwd(), ".agent-mail");
87
+ if (fs.existsSync(cwdMailDir)) {
88
+ return cwdMailDir;
89
+ }
90
+
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Retrieve list of registered agent handles for the given queue root
96
+ */
97
+ export function getAgentHandles(amqRoot) {
98
+ if (!amqRoot || !fs.existsSync(amqRoot)) return [];
99
+
100
+ // Check config.json locations
101
+ for (const configPath of [
102
+ path.join(amqRoot, "meta", "config.json"),
103
+ path.join(amqRoot, "config.json"),
104
+ ]) {
105
+ if (fs.existsSync(configPath)) {
106
+ try {
107
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
108
+ if (Array.isArray(parsed.agents)) {
109
+ return parsed.agents
110
+ .map((a) => (typeof a === "string" ? a : a.handle))
111
+ .filter(Boolean);
112
+ }
113
+ } catch {}
114
+ }
115
+ }
116
+
117
+ // Fallback: list agent folders
118
+ const agentsDir = path.join(amqRoot, "agents");
119
+ if (fs.existsSync(agentsDir)) {
120
+ try {
121
+ return fs.readdirSync(agentsDir).filter((name) => {
122
+ return fs.statSync(path.join(agentsDir, name)).isDirectory() && !name.startsWith(".");
123
+ });
124
+ } catch {}
125
+ }
126
+
127
+ return [];
128
+ }
129
+
130
+ /**
131
+ * Execute command with PATH updated to include ~/.local/bin
132
+ */
133
+ export function execCmd(bin, args, options = {}) {
134
+ const env = {
135
+ ...process.env,
136
+ PATH: `${process.env.HOME}/.local/bin:${process.env.PATH || ""}`,
137
+ ...(options.env || {}),
138
+ };
139
+
140
+ return execFileSync(bin, args, {
141
+ encoding: "utf8",
142
+ stdio: ["ignore", "pipe", "pipe"],
143
+ ...options,
144
+ env,
145
+ });
146
+ }