herdr-plugin-amq 0.1.7 → 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/bridge.mjs CHANGED
@@ -6,9 +6,15 @@ import {
6
6
  getStateDir,
7
7
  getConfigDir,
8
8
  findAmqRoot,
9
+ getRepoRootFromAmq,
9
10
  getAgentHandles,
10
11
  execCmd,
11
12
  } from "./config.mjs";
13
+ import { listBacklogTasks, getAgentTaskStats } from "./board.mjs";
14
+ import { loadLocalTemplate, renderTemplate } from "./templates.mjs";
15
+ import { isSafeMailIdentifier, listMaildirMessageFiles, readMaildirMessageFile, writeBoundedFileAtomic } from "./protocol.mjs";
16
+
17
+ const MAX_DOORBELL_PROMPT_BYTES = 64 * 1024;
12
18
 
13
19
  function getPidFile() {
14
20
  return path.join(getStateDir(), "bridge.pid");
@@ -120,12 +126,13 @@ function healAgentName(handle, dryRun = false) {
120
126
  }
121
127
 
122
128
  function promptAgent(handle, text, dryRun = false) {
129
+ const boundedText = boundDoorbellPrompt(text);
123
130
  if (dryRun || process.env.HERDR_DISABLE_PROMPT === "1" || process.env.NODE_ENV === "test") {
124
- console.log(`[bridge] DRY: would prompt ${handle}: ${text.slice(0, 60)}...`);
131
+ console.log(`[bridge] DRY: would prompt ${handle}: ${boundedText.slice(0, 60)}...`);
125
132
  return true;
126
133
  }
127
134
  try {
128
- runHerdr(["agent", "prompt", handle, text]);
135
+ runHerdr(["agent", "prompt", handle, boundedText]);
129
136
  return true;
130
137
  } catch (err) {
131
138
  console.warn(`[bridge] Warning: prompt ${handle} failed: ${err.message}`);
@@ -149,12 +156,50 @@ function recordAlert(handle, count, from, dryRun = false) {
149
156
  } catch {}
150
157
  }
151
158
 
159
+ function safeStateText(value, maxLength = 512) {
160
+ if (typeof value !== "string") return "";
161
+ return value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength);
162
+ }
163
+
164
+ function sanitizeDeliveredState(value) {
165
+ const delivered = {};
166
+ const deliveredTasks = {};
167
+ if (!value || typeof value !== "object") return { delivered, deliveredTasks, recoveryRequired: true };
168
+
169
+ for (const [id, entry] of Object.entries(value.delivered || {})) {
170
+ if (!isSafeMailIdentifier(id) || !entry || typeof entry !== "object") continue;
171
+ if (!isSafeMailIdentifier(entry.to, 128) || !isSafeMailIdentifier(entry.from, 128)) continue;
172
+ if (typeof entry.at !== "string") continue;
173
+ delivered[id] = {
174
+ at: entry.at,
175
+ to: entry.to,
176
+ from: entry.from,
177
+ attempts: Number.isFinite(entry.attempts) ? Math.max(1, Math.trunc(entry.attempts)) : 1,
178
+ };
179
+ }
180
+
181
+ for (const [id, entry] of Object.entries(value.deliveredTasks || {})) {
182
+ if (!isSafeMailIdentifier(id) || !entry || typeof entry !== "object") continue;
183
+ if (!isSafeMailIdentifier(entry.to, 128) || typeof entry.at !== "string") continue;
184
+ deliveredTasks[id] = {
185
+ at: entry.at,
186
+ to: entry.to,
187
+ title: safeStateText(entry.title),
188
+ attempts: Number.isFinite(entry.attempts) ? Math.max(1, Math.trunc(entry.attempts)) : 1,
189
+ };
190
+ }
191
+
192
+ return { delivered, deliveredTasks, recoveryRequired: false };
193
+ }
194
+
152
195
  function loadDeliveredState() {
153
196
  const stateFile = getStateFile();
154
197
  try {
155
- return JSON.parse(fs.readFileSync(stateFile, "utf8"));
198
+ const content = readMaildirMessageFile(stateFile, 2 * 1024 * 1024);
199
+ if (content === null) return { delivered: {}, deliveredTasks: {}, recoveryRequired: true };
200
+ return sanitizeDeliveredState(JSON.parse(content));
156
201
  } catch {
157
- return { delivered: {} };
202
+ return { delivered: {}, deliveredTasks: {}, recoveryRequired: true };
158
203
  }
159
204
  }
160
205
 
@@ -167,7 +212,14 @@ function saveDeliveredState(state) {
167
212
  delete state.delivered[id];
168
213
  }
169
214
  }
170
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 1), "utf8");
215
+ const taskIds = Object.keys(state.deliveredTasks || {});
216
+ if (taskIds.length > 2000) {
217
+ taskIds.sort();
218
+ for (const id of taskIds.slice(0, taskIds.length - 1500)) {
219
+ delete state.deliveredTasks[id];
220
+ }
221
+ }
222
+ writeBoundedFileAtomic(stateFile, JSON.stringify(state, null, 1), 2 * 1024 * 1024);
171
223
  }
172
224
 
173
225
  export function listInbox(amqRoot, handle) {
@@ -190,11 +242,12 @@ export function listInbox(amqRoot, handle) {
190
242
  try {
191
243
  const newDir = path.join(amqRoot, "agents", handle, "inbox", "new");
192
244
  if (!fs.existsSync(newDir)) return [];
193
- const files = fs.readdirSync(newDir).filter((f) => !f.startsWith("."));
245
+ const files = listMaildirMessageFiles(newDir).filter((f) => !f.startsWith("."));
194
246
  const msgs = [];
195
247
  for (const f of files) {
196
248
  const fullPath = path.join(newDir, f);
197
- const content = fs.readFileSync(fullPath, "utf8");
249
+ const content = readMaildirMessageFile(fullPath);
250
+ if (content === null) continue;
198
251
  const jsonMatch = content.match(/^---json\r?\n([\s\S]*?)\r?\n---/);
199
252
  if (jsonMatch) {
200
253
  try {
@@ -239,16 +292,115 @@ export function listInbox(amqRoot, handle) {
239
292
  }
240
293
  }
241
294
 
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
- );
295
+ function boundDoorbellPrompt(text, maxBytes = MAX_DOORBELL_PROMPT_BYTES) {
296
+ const value = typeof text === "string" ? text : String(text ?? "");
297
+ const bytes = Buffer.from(value, "utf8");
298
+ return bytes.length <= maxBytes
299
+ ? value
300
+ : bytes.subarray(0, Math.max(0, maxBytes)).toString("utf8");
301
+ }
302
+
303
+ function safeCount(value) {
304
+ const count = Number(value);
305
+ return Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0;
306
+ }
307
+
308
+ function safeSender(value) {
309
+ const sender = typeof value === "string" ? value.trim() : "";
310
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(sender) ? sender : "unknown";
311
+ }
312
+
313
+ function normalizeDoorbellStats(taskStatsOrBacklog) {
314
+ const raw = Array.isArray(taskStatsOrBacklog)
315
+ ? { backlog: taskStatsOrBacklog.length, blocked: 0, doing: 0, done: 0 }
316
+ : taskStatsOrBacklog && typeof taskStatsOrBacklog === "object"
317
+ ? taskStatsOrBacklog
318
+ : { backlog: 0, blocked: 0, doing: 0, done: 0 };
319
+ const stats = {
320
+ backlog: safeCount(raw.backlog),
321
+ blocked: safeCount(raw.blocked),
322
+ doing: safeCount(raw.doing),
323
+ done: safeCount(raw.done),
324
+ };
325
+ stats.total = safeCount(raw.total ?? Object.values(stats).reduce((sum, value) => sum + value, 0));
326
+ return stats;
327
+ }
328
+
329
+ function buildDoorbellContext(handle, msgs, stats) {
330
+ return {
331
+ agent: { handle },
332
+ mail: {
333
+ count: safeCount(msgs.length),
334
+ senders: [...new Set(msgs.map((message) => safeSender(message.from)))].slice(0, 16).join(", "),
335
+ },
336
+ board: stats,
337
+ };
338
+ }
339
+
340
+ function buildRequiredDoorbellActions(handle, context) {
341
+ const actions = [];
342
+ if (context.mail.count > 0) {
343
+ actions.push(`Run: herdr-amq mail drain --me ${handle} --include-body.`);
344
+ }
345
+ if (context.board.backlog > 0) {
346
+ actions.push(`Run: herdr-amq task drain --me ${handle}; claim with herdr-amq task next --me ${handle}.`);
347
+ }
348
+ if (context.mail.count > 0) {
349
+ actions.push("Reply only when a message explicitly requests action or asks a question.");
350
+ }
351
+ return actions.join(" ");
352
+ }
353
+
354
+ function buildDefaultDoorbellPrompt(handle, msgs, stats) {
355
+ const context = buildDoorbellContext(handle, msgs, stats);
356
+ const taskDetails = [];
357
+ if (context.board.blocked > 0) taskDetails.push(`${context.board.blocked} blocked`);
358
+ if (context.board.doing > 0) taskDetails.push(`${context.board.doing} in progress`);
359
+ if (context.board.done > 0) taskDetails.push(`${context.board.done} done`);
360
+ const details = taskDetails.length ? ` (${taskDetails.join(", ")})` : "";
361
+ let summary;
362
+ if (context.mail.count > 0 && context.board.backlog > 0) {
363
+ summary = `AMQ & Task doorbell: ${context.mail.count} new message(s) from ${context.mail.senders}. You have ${context.board.backlog} task(s) in backlog${details}.`;
364
+ } else if (context.board.backlog > 0) {
365
+ summary = `Task doorbell: You have ${context.board.backlog} task(s) in backlog${details}.`;
366
+ } else {
367
+ summary = `AMQ doorbell: ${context.mail.count} new message(s) in your inbox from ${context.mail.senders}${details}.`;
368
+ }
369
+ return boundDoorbellPrompt(`${summary} ${buildRequiredDoorbellActions(handle, context)}`.trim());
370
+ }
371
+
372
+ export const buildDoorbellPrompt = (handle, msgs = [], taskStatsOrBacklog = null, templateSource = null) => {
373
+ const stats = normalizeDoorbellStats(taskStatsOrBacklog);
374
+ const context = buildDoorbellContext(handle, msgs, stats);
375
+ const fallback = buildDefaultDoorbellPrompt(handle, msgs, stats);
376
+ if (!templateSource) return fallback;
377
+
378
+ try {
379
+ const custom = renderTemplate(templateSource, context).trim();
380
+ if (!custom) return fallback;
381
+ const requiredActions = `Required actions: ${buildRequiredDoorbellActions(handle, context)}`;
382
+ const bodyLimit = Math.max(0, MAX_DOORBELL_PROMPT_BYTES - Buffer.byteLength(requiredActions, "utf8") - 2);
383
+ return `${boundDoorbellPrompt(custom, bodyLimit)}\n\n${requiredActions}`;
384
+ } catch {
385
+ return fallback;
386
+ }
250
387
  };
251
388
 
389
+ export const DEFAULT_DOORBELL_COOLDOWN_MS = 45000; // 45s cooldown window
390
+
391
+ /**
392
+ * Determines whether an item was recently doorbelled and is still in-flight.
393
+ * If the item has not been drained after the cooldown window, it no longer
394
+ * counts as delivered and will be re-doorbelled.
395
+ */
396
+ export function isItemPendingDrain(deliveryEntry, cooldownMs = DEFAULT_DOORBELL_COOLDOWN_MS, force = false) {
397
+ if (force) return false;
398
+ if (!deliveryEntry || !deliveryEntry.at) return false;
399
+ const deliveredAt = new Date(deliveryEntry.at).getTime();
400
+ if (isNaN(deliveredAt)) return false;
401
+ return Date.now() - deliveredAt < cooldownMs;
402
+ }
403
+
252
404
  // ─── Doorbell Pass ────────────────────────────────────────────────────────────
253
405
 
254
406
  export function runDoorbellPass({
@@ -256,6 +408,14 @@ export function runDoorbellPass({
256
408
  handles = null,
257
409
  targetHandle = null,
258
410
  dryRun = false,
411
+ force = false,
412
+ allowPrompt = false,
413
+ persistState = false,
414
+ state: injectedState = null,
415
+ getStatus = getAgentStatus,
416
+ healName = healAgentName,
417
+ prompt = promptAgent,
418
+ cooldownMs = parseInt(process.env.HERDR_DOORBELL_COOLDOWN_MS || String(DEFAULT_DOORBELL_COOLDOWN_MS), 10),
259
419
  } = {}) {
260
420
  if (!amqRoot) {
261
421
  return { ok: false, error: "No .agent-mail queue found." };
@@ -266,59 +426,113 @@ export function runDoorbellPass({
266
426
  : (handles && handles.length > 0 ? handles : getAgentHandles(amqRoot));
267
427
 
268
428
  if (!agentList.length) {
269
- return { ok: true, checked: 0, doorbelled: 0, message: "No registered agents found." };
429
+ return { ok: true, checked: 0, doorbelled: 0, doorbelledTasks: 0, message: "No registered agents found." };
270
430
  }
271
431
 
272
- const state = loadDeliveredState();
432
+ const repoRoot = getRepoRootFromAmq(amqRoot);
433
+ const doorbellTemplate = loadLocalTemplate(amqRoot, "doorbell");
434
+ const state = injectedState || loadDeliveredState();
273
435
  state.delivered = state.delivered || {};
436
+ state.deliveredTasks = state.deliveredTasks || {};
274
437
 
275
438
  let doorbelledCount = 0;
439
+ let doorbelledTasksCount = 0;
276
440
  const results = [];
277
441
 
278
442
  for (const handle of agentList) {
443
+ if (typeof handle !== "string" || !/^[a-z0-9_-]{1,128}$/.test(handle)) {
444
+ results.push({ handle: String(handle), status: "invalid", count: 0, tasksCount: 0, action: "invalid_handle" });
445
+ continue;
446
+ }
279
447
  const rawMsgs = listInbox(amqRoot, handle);
280
448
  // Ignore self-messages
281
449
  const msgs = rawMsgs.filter((m) => m.from !== handle);
450
+ // If message is still in inbox/new after cooldown, the agent did NOT drain it!
451
+ const undeliveredMsgs = msgs.filter((m) => !isItemPendingDrain(state.delivered[m.id], cooldownMs, force));
282
452
 
283
- if (!msgs.length) continue;
453
+ const backlogTasks = listBacklogTasks(repoRoot, amqRoot, handle);
454
+ // If task is still in backlog after cooldown, the agent did NOT claim/drain it!
455
+ const undeliveredTasks = backlogTasks.filter((t) => !isItemPendingDrain(state.deliveredTasks?.[t.id], cooldownMs, force));
284
456
 
285
- // Filter messages not yet delivered
286
- const undelivered = msgs.filter((m) => !state.delivered[m.id]);
287
- if (!undelivered.length) continue;
457
+ if (!undeliveredMsgs.length && !undeliveredTasks.length) continue;
288
458
 
289
- let status = getAgentStatus(handle);
459
+ let status = getStatus(handle);
290
460
  if (status === "missing") {
291
- const healed = healAgentName(handle, dryRun);
461
+ const healed = healName(
462
+ handle,
463
+ dryRun || !allowPrompt || process.env.HERDR_DISABLE_PROMPT === "1"
464
+ );
292
465
  if (healed) status = getAgentStatus(handle);
293
466
  }
294
467
 
468
+ const taskStats = getAgentTaskStats(repoRoot, amqRoot, handle);
469
+ taskStats.backlog = undeliveredTasks.length > 0 ? undeliveredTasks.length : backlogTasks.length;
470
+
295
471
  if (status === "idle" || status === "done") {
296
- const text = buildDoorbellPrompt(handle, undelivered);
297
- const ok = promptAgent(handle, text, dryRun);
472
+ const text = buildDoorbellPrompt(handle, undeliveredMsgs, taskStats, doorbellTemplate?.source || null);
473
+ const ok = prompt(handle, text, dryRun || !allowPrompt);
298
474
  if (ok) {
299
- doorbelledCount += undelivered.length;
475
+ doorbelledCount += undeliveredMsgs.length;
476
+ doorbelledTasksCount += undeliveredTasks.length;
300
477
  if (!dryRun) {
301
- for (const m of undelivered) {
478
+ for (const m of undeliveredMsgs) {
479
+ const prev = state.delivered[m.id];
302
480
  state.delivered[m.id] = {
303
481
  at: new Date().toISOString(),
304
482
  to: handle,
305
483
  from: m.from,
484
+ attempts: (prev?.attempts || 0) + 1,
485
+ };
486
+ }
487
+ for (const t of undeliveredTasks) {
488
+ const prev = state.deliveredTasks[t.id];
489
+ state.deliveredTasks[t.id] = {
490
+ at: new Date().toISOString(),
491
+ to: handle,
492
+ title: t.title,
493
+ attempts: (prev?.attempts || 0) + 1,
306
494
  };
307
495
  }
308
496
  }
309
- results.push({ handle, status, count: undelivered.length, action: "prompted" });
497
+ results.push({
498
+ handle,
499
+ status,
500
+ count: undeliveredMsgs.length,
501
+ tasksCount: undeliveredTasks.length,
502
+ action: allowPrompt && !dryRun ? "prompted" : "simulated",
503
+ });
310
504
  }
311
505
  } else if (status === "working") {
312
- results.push({ handle, status, count: undelivered.length, action: "working_wait" });
506
+ results.push({
507
+ handle,
508
+ status,
509
+ count: undeliveredMsgs.length,
510
+ tasksCount: undeliveredTasks.length,
511
+ action: "working_wait",
512
+ });
313
513
  } else if (status === "blocked") {
314
- recordAlert(handle, undelivered.length, undelivered[0]?.from, dryRun);
315
- results.push({ handle, status, count: undelivered.length, action: "alert_blocked" });
514
+ if (undeliveredMsgs.length) {
515
+ recordAlert(handle, undeliveredMsgs.length, undeliveredMsgs[0]?.from, dryRun || !allowPrompt);
516
+ }
517
+ results.push({
518
+ handle,
519
+ status,
520
+ count: undeliveredMsgs.length,
521
+ tasksCount: undeliveredTasks.length,
522
+ action: "alert_blocked",
523
+ });
316
524
  } else {
317
- results.push({ handle, status, count: undelivered.length, action: "unknown_state" });
525
+ results.push({
526
+ handle,
527
+ status,
528
+ count: undeliveredMsgs.length,
529
+ tasksCount: undeliveredTasks.length,
530
+ action: "unknown_state",
531
+ });
318
532
  }
319
533
  }
320
534
 
321
- if (!dryRun && doorbelledCount > 0) {
535
+ if (allowPrompt && persistState && !dryRun && (doorbelledCount > 0 || doorbelledTasksCount > 0)) {
322
536
  saveDeliveredState(state);
323
537
  }
324
538
 
@@ -327,6 +541,7 @@ export function runDoorbellPass({
327
541
  amqRoot,
328
542
  agentsChecked: agentList.length,
329
543
  doorbelled: doorbelledCount,
544
+ doorbelledTasks: doorbelledTasksCount,
330
545
  results,
331
546
  };
332
547
  }
@@ -359,7 +574,7 @@ export function startDaemonLoop({ interval = 3000, dryRun = false } = {}) {
359
574
  const tick = () => {
360
575
  try {
361
576
  const handles = getAgentHandles(amqRoot);
362
- const res = runDoorbellPass({ amqRoot, handles, dryRun });
577
+ const res = runDoorbellPass({ amqRoot, handles, dryRun, allowPrompt: true, persistState: true });
363
578
  if (res.doorbelled > 0) {
364
579
  console.log(`[bridge] Doorbelled ${res.doorbelled} message(s)`);
365
580
  }
package/src/config.mjs CHANGED
@@ -1,13 +1,37 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { execFileSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ export function getPluginVersion() {
9
+ try {
10
+ const pkgPath = path.resolve(__dirname, "../package.json");
11
+ if (fs.existsSync(pkgPath)) {
12
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
13
+ return pkg.version || "unknown";
14
+ }
15
+ } catch {}
16
+ return "unknown";
17
+ }
4
18
 
5
19
  export function getHerdrBin() {
6
20
  return process.env.HERDR_BIN_PATH || "herdr";
7
21
  }
8
22
 
9
23
  export function getStateDir() {
10
- const dir = process.env.HERDR_PLUGIN_STATE_DIR || path.join(process.env.HOME || "/tmp", ".herdr-amq-state");
24
+ if (process.env.HERDR_PLUGIN_STATE_DIR) {
25
+ const dir = process.env.HERDR_PLUGIN_STATE_DIR;
26
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
27
+ return dir;
28
+ }
29
+ const legacyDir = path.join(process.env.HOME || "/tmp", ".herdr-amq-state");
30
+ if (fs.existsSync(legacyDir)) {
31
+ return legacyDir;
32
+ }
33
+ const base = process.env.XDG_STATE_HOME || path.join(process.env.HOME || "/tmp", ".local", "state");
34
+ const dir = path.join(base, "herdr-amq");
11
35
  if (!fs.existsSync(dir)) {
12
36
  fs.mkdirSync(dir, { recursive: true });
13
37
  }
@@ -22,7 +46,17 @@ export function getRepoRootFromAmq(amqRoot) {
22
46
  }
23
47
 
24
48
  export function getConfigDir() {
25
- const dir = process.env.HERDR_PLUGIN_CONFIG_DIR || path.join(process.env.HOME || "/tmp", ".herdr-amq-config");
49
+ if (process.env.HERDR_PLUGIN_CONFIG_DIR) {
50
+ const dir = process.env.HERDR_PLUGIN_CONFIG_DIR;
51
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
52
+ return dir;
53
+ }
54
+ const legacyDir = path.join(process.env.HOME || "/tmp", ".herdr-amq-config");
55
+ if (fs.existsSync(legacyDir)) {
56
+ return legacyDir;
57
+ }
58
+ const base = process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || "/tmp", ".config");
59
+ const dir = path.join(base, "herdr-amq");
26
60
  if (!fs.existsSync(dir)) {
27
61
  fs.mkdirSync(dir, { recursive: true });
28
62
  }