herdr-plugin-amq 0.1.3 → 0.1.5

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/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "cabra.amq"
2
2
  name = "Herdr AMQ"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  min_herdr_version = "0.7.0"
5
5
  description = "Agent Message Queue (AMQ) bridge daemon, mailbox monitor, and dashboard for Herdr"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-plugin-amq",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Herdr plugin for AMQ (Agent Message Queue) autonomous bridge, status monitoring, and AGmail dashboard",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
package/src/blobs.mjs CHANGED
@@ -159,22 +159,97 @@ export function getBlob(sha256, amqRoot) {
159
159
 
160
160
  // ─── Option B: Git Commit & Object Pinning ────────────────────────────────────
161
161
 
162
+ const gitRefCache = new Map();
163
+ const headCommitCache = new Map();
164
+ const timestampCommitCache = new Map();
165
+
166
+ export function clearGitRefCache() {
167
+ gitRefCache.clear();
168
+ headCommitCache.clear();
169
+ timestampCommitCache.clear();
170
+ }
171
+
162
172
  /**
163
- * Pin a repository file to a specific commit or HEAD.
164
- * Returns immutable git ref descriptor.
173
+ * Resolve the git commit active before or at a given ISO timestamp.
165
174
  */
166
- export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
167
- if (!repoRoot || !relativePath) return null;
175
+ export function getCommitAtTimestamp(repoRoot, timestamp) {
176
+ if (!repoRoot || !timestamp) return null;
177
+ const iso = typeof timestamp === "string" ? timestamp.trim() : new Date(timestamp).toISOString();
178
+ const cacheKey = `${repoRoot}:${iso}`;
179
+ if (timestampCommitCache.has(cacheKey)) {
180
+ return timestampCommitCache.get(cacheKey);
181
+ }
168
182
 
169
183
  try {
170
- const cleanRel = relativePath.replace(/^[/\\]+/, "");
171
- // Resolve commit SHA
172
- const commitSha = execFileSync("git", ["rev-parse", commit], {
184
+ const commit = execFileSync("git", ["rev-list", "-n", "1", `--before=${iso}`, "HEAD"], {
173
185
  cwd: repoRoot,
174
186
  encoding: "utf8",
175
187
  stdio: ["ignore", "pipe", "ignore"],
176
188
  }).trim();
177
189
 
190
+ const result = commit && /^[a-f0-9]{7,40}$/i.test(commit) ? commit : null;
191
+ timestampCommitCache.set(cacheKey, result);
192
+ return result;
193
+ } catch {
194
+ timestampCommitCache.set(cacheKey, null);
195
+ return null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Extract commit hashes explicitly mentioned in message text.
201
+ */
202
+ export function extractMentionedCommits(text = "") {
203
+ if (!text || typeof text !== "string") return [];
204
+ const commits = [];
205
+ const regex = /\b(?:commit|commitado|commitada|fixado|merge|sha|rev)?[:\s*`"'(]*([0-9a-f]{7,40})\b/gi;
206
+ let match;
207
+ while ((match = regex.exec(text)) !== null) {
208
+ const hex = match[1].toLowerCase();
209
+ if (/^[0-9]+$/.test(hex) && hex.length < 40) continue;
210
+ if (!commits.includes(hex)) {
211
+ commits.push(hex);
212
+ }
213
+ }
214
+ return commits;
215
+ }
216
+
217
+ /**
218
+ * Pin a repository file to a specific commit or HEAD.
219
+ * Returns immutable git ref descriptor.
220
+ */
221
+ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
222
+ if (!repoRoot || !relativePath) return null;
223
+
224
+ const cleanRel = relativePath.replace(/^[/\\]+/, "");
225
+ const cacheKey = `${repoRoot}:${commit}:${cleanRel}`;
226
+ if (gitRefCache.has(cacheKey)) {
227
+ return gitRefCache.get(cacheKey);
228
+ }
229
+
230
+ try {
231
+ let commitSha;
232
+ if (commit === "HEAD") {
233
+ const cachedHead = headCommitCache.get(repoRoot);
234
+ const now = Date.now();
235
+ if (cachedHead && now - cachedHead.at < 5000) {
236
+ commitSha = cachedHead.sha;
237
+ } else {
238
+ commitSha = execFileSync("git", ["rev-parse", "HEAD"], {
239
+ cwd: repoRoot,
240
+ encoding: "utf8",
241
+ stdio: ["ignore", "pipe", "ignore"],
242
+ }).trim();
243
+ headCommitCache.set(repoRoot, { sha: commitSha, at: now });
244
+ }
245
+ } else {
246
+ commitSha = execFileSync("git", ["rev-parse", commit], {
247
+ cwd: repoRoot,
248
+ encoding: "utf8",
249
+ stdio: ["ignore", "pipe", "ignore"],
250
+ }).trim();
251
+ }
252
+
178
253
  // Resolve git blob hash
179
254
  const blobSha = execFileSync("git", ["rev-parse", `${commitSha}:${cleanRel}`], {
180
255
  cwd: repoRoot,
@@ -194,7 +269,7 @@ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
194
269
  const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
195
270
  const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
196
271
 
197
- return {
272
+ const ref = {
198
273
  type: "git",
199
274
  commit: commitSha,
200
275
  shortCommit: commitSha.slice(0, 10),
@@ -209,7 +284,10 @@ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
209
284
  exists: true,
210
285
  url: `/api/git-file?commit=${commitSha}&path=${encodeURIComponent(cleanRel)}`,
211
286
  };
287
+ gitRefCache.set(cacheKey, ref);
288
+ return ref;
212
289
  } catch {
290
+ gitRefCache.set(cacheKey, null);
213
291
  return null;
214
292
  }
215
293
  }
@@ -259,7 +337,7 @@ export function readGitRef(repoRoot, commitSha, relativePath) {
259
337
  * - Committed files in repoRoot can be pinned to git HEAD.
260
338
  * - Already pinned/stored items retain their immutable URLs.
261
339
  */
262
- export function ingestAttachment(candidate, amqRoot, repoRoot) {
340
+ export function ingestAttachment(candidate, amqRoot, repoRoot, { timestamp = null, text = "" } = {}) {
263
341
  if (!candidate) return null;
264
342
 
265
343
  // Already a structured descriptor
@@ -285,7 +363,7 @@ export function ingestAttachment(candidate, amqRoot, repoRoot) {
285
363
  }
286
364
 
287
365
  if (candidate.path) {
288
- return ingestAttachment(candidate.path, amqRoot, repoRoot);
366
+ return ingestAttachment(candidate.path, amqRoot, repoRoot, { timestamp, text });
289
367
  }
290
368
  }
291
369
 
@@ -332,16 +410,47 @@ export function ingestAttachment(candidate, amqRoot, repoRoot) {
332
410
  return storeBlob(diskPath, amqRoot, path.basename(diskPath));
333
411
  }
334
412
 
335
- // If it's inside repoRoot and git is present, try pinning to git
413
+ // If it's inside repoRoot and git is present, pin to commit at timestamp (or HEAD)
336
414
  if (repoRoot && diskPath.startsWith(repoRoot)) {
337
415
  const rel = path.relative(repoRoot, diskPath);
338
- const gitRef = pinGitRef(repoRoot, rel, "HEAD");
416
+ const commit = (timestamp ? getCommitAtTimestamp(repoRoot, timestamp) : null) || "HEAD";
417
+ const gitRef = pinGitRef(repoRoot, rel, commit);
339
418
  if (gitRef) return gitRef;
340
419
  }
341
420
 
342
421
  // Default fallback for existing files: store into blobstore for guarantee
343
422
  return storeBlob(diskPath, amqRoot, path.basename(diskPath));
344
423
  }
424
+
425
+ // ─── Historical Git Pinning Fallback ───
426
+ // If the file does not exist on disk right now (e.g. deleted or renamed in subsequent commits),
427
+ // check if it existed in Git at the time the message was created or at a commit mentioned in the message!
428
+ if (!diskPath && repoRoot) {
429
+ const cleanRel = raw.startsWith(repoRoot) ? path.relative(repoRoot, raw) : raw.replace(/^[/\\]+/, "");
430
+ if (!cleanRel.includes("..") && !path.isAbsolute(cleanRel)) {
431
+ const candidateCommits = [];
432
+ if (text) {
433
+ candidateCommits.push(...extractMentionedCommits(text));
434
+ }
435
+ if (timestamp) {
436
+ const atTime = getCommitAtTimestamp(repoRoot, timestamp);
437
+ if (atTime && !candidateCommits.includes(atTime)) {
438
+ candidateCommits.push(atTime);
439
+ }
440
+ }
441
+ candidateCommits.push("HEAD");
442
+
443
+ for (const commitSha of candidateCommits) {
444
+ const gitRef = pinGitRef(repoRoot, cleanRel, commitSha);
445
+ if (gitRef && gitRef.exists) {
446
+ return {
447
+ ...gitRef,
448
+ pinnedAt: timestamp || null,
449
+ };
450
+ }
451
+ }
452
+ }
453
+ }
345
454
  }
346
455
 
347
456
  return null;
package/src/panes.mjs CHANGED
@@ -18,11 +18,15 @@ export function launchDashboardPane() {
18
18
  const port = parseInt(process.env.AGMAIL_PORT || "8505", 10);
19
19
  const server = startWebServer({ port, amqRoot });
20
20
 
21
- // Open browser in background if xdg-open exists
22
- try {
23
- const opener = process.platform === "darwin" ? "open" : "xdg-open";
24
- spawn(opener, [`http://localhost:${port}`], { stdio: "ignore", detached: true }).unref();
25
- } catch {}
21
+ // Open browser in background if display is available and not disabled
22
+ if (process.env.NO_OPEN !== "1" && process.env.DISPLAY) {
23
+ server.once("listening", () => {
24
+ try {
25
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
26
+ spawn(opener, [`http://127.0.0.1:${port}`], { stdio: "ignore", detached: true }).unref();
27
+ } catch {}
28
+ });
29
+ }
26
30
 
27
31
  console.log("\nPress Ctrl+C to stop the dashboard server.");
28
32
  }
package/src/server.mjs CHANGED
@@ -180,7 +180,7 @@ export function startWebServer({
180
180
 
181
181
 
182
182
 
183
- const server = http.createServer(async (req, res) => {
183
+ const requestHandler = async (req, res) => {
184
184
  // ─── Compliance: Strict Security Headers ───────────────────────────────
185
185
  res.setHeader("X-Content-Type-Options", "nosniff");
186
186
  res.setHeader("X-Frame-Options", "DENY");
@@ -191,12 +191,18 @@ export function startWebServer({
191
191
  res.setHeader("Referrer-Policy", "no-referrer");
192
192
 
193
193
  // ─── Compliance: Host Header & DNS Rebinding Protection ───────────────
194
- const rawHost = req.headers.host || "";
195
- const hostHeader = rawHost.split(":")[0].toLowerCase();
194
+ const rawHost = (req.headers.host || "").toLowerCase().trim();
195
+ let hostHeader = rawHost;
196
+ if (hostHeader.startsWith("[")) {
197
+ const closeBracket = hostHeader.indexOf("]");
198
+ hostHeader = closeBracket !== -1 ? hostHeader.slice(1, closeBracket) : hostHeader;
199
+ } else {
200
+ hostHeader = hostHeader.split(":")[0];
201
+ }
202
+
196
203
  const isLocalHost =
197
204
  hostHeader === "localhost" ||
198
205
  hostHeader === "127.0.0.1" ||
199
- hostHeader === "[::1]" ||
200
206
  hostHeader === "::1" ||
201
207
  !rawHost; // In-memory or direct tests without host header
202
208
 
@@ -718,24 +724,60 @@ export function startWebServer({
718
724
  res.writeHead(404, { "Content-Type": "text/plain" });
719
725
  res.end("File not found");
720
726
  }
721
- });
727
+ };
728
+
729
+ const server = http.createServer(requestHandler);
730
+ let server6 = null;
722
731
 
723
732
  server.listen(port, host, () => {
724
- console.log(`\x1b[32m● AGmail Webmail Server running at:\x1b[0m \x1b[1mhttp://${host}:${port}\x1b[0m (local only)`);
733
+ const boundPort = server.address()?.port || port;
734
+ console.log(`\x1b[32m● AGmail Webmail Server running at:\x1b[0m \x1b[1mhttp://${host}:${boundPort}\x1b[0m (local only)`);
725
735
  console.log(` Queue Root: \x1b[36m${amqRoot}\x1b[0m`);
726
736
  if (host !== "127.0.0.1" && host !== "localhost") {
727
737
  console.warn(`\x1b[33m⚠️ SECURITY WARNING: Server is listening on '${host}'. AGmail contains sensitive agent data and should strictly be local-only!\x1b[0m`);
728
738
  }
729
- // Workspaces are the default: automatically isolate agents in worktrees silently
730
- try {
731
- const repoRoot = getRepoRootFromAmq(amqRoot);
732
- const handles = getAgentHandles(amqRoot);
733
- ensureAllWorktrees(repoRoot, handles);
734
- } catch {}
739
+
740
+ // If host is loopback, also listen on ::1 so browser 'localhost' connects seamlessly in IPv6-first browsers (Firefox/Chrome)
741
+ if (host === "127.0.0.1" || host === "localhost") {
742
+ try {
743
+ server6 = http.createServer(requestHandler);
744
+ server6.listen(boundPort, "::1", () => {});
745
+ server6.on("error", () => {
746
+ // Graceful fallback if system does not support IPv6 loopback
747
+ server6 = null;
748
+ });
749
+ } catch {}
750
+ }
751
+
752
+ // Workspaces are the default: automatically isolate agents in worktrees in background
753
+ setImmediate(() => {
754
+ try {
755
+ const repoRoot = getRepoRootFromAmq(amqRoot);
756
+ const handles = getAgentHandles(amqRoot);
757
+ ensureAllWorktrees(repoRoot, handles);
758
+ } catch {}
759
+ });
735
760
  });
736
761
 
762
+ const originalCloseAll = server.closeAllConnections?.bind(server);
763
+ server.closeAllConnections = function () {
764
+ if (originalCloseAll) originalCloseAll();
765
+ if (server6 && typeof server6.closeAllConnections === "function") {
766
+ try { server6.closeAllConnections(); } catch {}
767
+ }
768
+ };
769
+
737
770
  server.on("close", () => {
738
771
  isClosing = true;
772
+ if (server6) {
773
+ try {
774
+ if (typeof server6.closeAllConnections === "function") {
775
+ server6.closeAllConnections();
776
+ }
777
+ server6.close();
778
+ } catch {}
779
+ server6 = null;
780
+ }
739
781
  if (herdrReconnectTimeout) clearTimeout(herdrReconnectTimeout);
740
782
  if (watchDebounce) clearTimeout(watchDebounce);
741
783
  if (herdrSubscription) {
package/src/store.mjs CHANGED
@@ -34,30 +34,6 @@ export function formatAgentTitle(handle = "") {
34
34
  const filePathCache = new Map();
35
35
  let cacheTimestamp = 0;
36
36
 
37
- function findInTree(dir, targetName) {
38
- const skip = new Set([".git", ".godot", "node_modules", ".agent-mail", "dist", "build"]);
39
- const queue = [dir];
40
- while (queue.length > 0) {
41
- const current = queue.shift();
42
- let entries;
43
- try {
44
- entries = fs.readdirSync(current, { withFileTypes: true });
45
- } catch {
46
- continue;
47
- }
48
- for (const ent of entries) {
49
- if (ent.isDirectory()) {
50
- if (!skip.has(ent.name)) {
51
- queue.push(path.join(current, ent.name));
52
- }
53
- } else if (ent.name === targetName) {
54
- return path.join(current, ent.name);
55
- }
56
- }
57
- }
58
- return null;
59
- }
60
-
61
37
  export function formatFileSize(bytes) {
62
38
  if (!bytes || bytes <= 0) return "0 B";
63
39
  if (bytes < 1024) return `${bytes} B`;
@@ -178,14 +154,7 @@ export function resolveAttachmentPath(ref, amqRoot) {
178
154
  if (candidate) return candidate;
179
155
  }
180
156
 
181
- // 6. Fast tree walk in repoRoot for the basename
182
- const found = findInTree(repoRoot, base);
183
- if (found) {
184
- const resolved = path.resolve(found);
185
- filePathCache.set(cacheKey, resolved);
186
- return resolved;
187
- }
188
-
157
+ // 6. Not found in standard locations: cache null
189
158
  filePathCache.set(cacheKey, null);
190
159
  return null;
191
160
  }
@@ -194,7 +163,7 @@ export function resolveAttachmentPath(ref, amqRoot) {
194
163
  * Extract attachments and referenced images/logs from body or frontmatter,
195
164
  * verifying presence on disk and providing existence flags.
196
165
  */
197
- export function extractAttachments(body = "", metaAttachments = [], amqRoot = null) {
166
+ export function extractAttachments(body = "", metaAttachments = [], amqRoot = null, meta = {}) {
198
167
  const attachments = [];
199
168
  const seen = new Set();
200
169
  const repoRoot = getRepoRootFromAmq(amqRoot);
@@ -215,10 +184,13 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
215
184
  return;
216
185
  }
217
186
 
218
- // 1. Try hybrid CAS ingestion / Git pinning (Option A + B)
187
+ // 1. Try hybrid CAS ingestion / Git pinning (Option A + B) with timestamp
219
188
  if (amqRoot) {
220
189
  try {
221
- const ingested = ingestAttachment(rawRef, amqRoot, repoRoot);
190
+ const ingested = ingestAttachment(rawRef, amqRoot, repoRoot, {
191
+ timestamp: meta?.created || null,
192
+ text: body,
193
+ });
222
194
  if (ingested && ingested.exists) {
223
195
  attachments.push({
224
196
  ...ingested,
@@ -264,8 +236,8 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
264
236
  }
265
237
  }
266
238
 
267
- // Auto-scan body for referenced files (/tmp/..., /nix/..., or relative paths)
268
- const regex = /(?:(?:\/(?:tmp|home|nix)[\w./-]+)|(?:[\w./-]+))\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|txt|csv|json|diff|patch|out|gd|tres|tscn|sh|md)\b/gi;
239
+ // Auto-scan body for referenced files (/tmp/..., paths with slashes, or image/log basenames)
240
+ const regex = /(?:(?:(?:\/|\.\/|[a-zA-Z0-9_.-]+\/)[a-zA-Z0-9_./-]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|txt|csv|json|diff|patch|out|gd|tres|tscn|sh|md))|(?:\b[a-zA-Z0-9_.-]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|diff|patch|out)\b))/gi;
269
241
  const matches = body.match(regex) || [];
270
242
 
271
243
  for (const m of matches) {
@@ -425,7 +397,7 @@ export function parseMessageFile(filePath, amqRoot = null) {
425
397
  if (isOutbox) folder = "sent";
426
398
 
427
399
  const root = amqRoot || (filePath.includes("/agents/") ? filePath.split(path.sep + "agents" + path.sep)[0] : null);
428
- const attachments = extractAttachments(body, meta.attachments, root);
400
+ const attachments = extractAttachments(body, meta.attachments, root, meta);
429
401
  const hasImage = attachments.some((a) => a.isImage);
430
402
  const hasAttachment = attachments.length > 0;
431
403