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/LICENSE +21 -0
- package/README.md +263 -0
- package/bin/herdr-amq.mjs +74 -0
- package/herdr-plugin.toml +57 -0
- package/package.json +50 -0
- package/skills/herdr-amq/SKILL.md +69 -0
- package/src/actions.mjs +573 -0
- package/src/blobs.mjs +348 -0
- package/src/board.mjs +760 -0
- package/src/bridge.mjs +373 -0
- package/src/briefs.mjs +201 -0
- package/src/config.mjs +146 -0
- package/src/herdr.mjs +215 -0
- package/src/index.mjs +4 -0
- package/src/markdown.mjs +167 -0
- package/src/panes.mjs +68 -0
- package/src/protocol.mjs +347 -0
- package/src/server.mjs +773 -0
- package/src/store.mjs +1063 -0
- package/src/web/app.js +3066 -0
- package/src/web/index.html +727 -0
- package/src/web/style.css +3842 -0
- package/src/worktrees.mjs +217 -0
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
|
+
}
|