herdr-plugin-amq 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # Herdr AMQ Plugin
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/herdr-plugin-amq.svg)](https://www.npmjs.com/package/herdr-plugin-amq)
3
4
  [![CI](https://github.com/cabra-lat/herdr-plugin-amq/actions/workflows/ci.yml/badge.svg)](https://github.com/cabra-lat/herdr-plugin-amq/actions/workflows/ci.yml)
4
5
  [![Security](https://github.com/cabra-lat/herdr-plugin-amq/actions/workflows/security.yml/badge.svg)](https://github.com/cabra-lat/herdr-plugin-amq/actions/workflows/security.yml)
5
6
  [![Tests](https://img.shields.io/badge/tests-95%20passing-brightgreen.svg)](https://github.com/cabra-lat/herdr-plugin-amq)
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "cabra.amq"
2
2
  name = "Herdr AMQ"
3
- version = "0.1.0"
3
+ version = "0.1.4"
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.2",
3
+ "version": "0.1.4",
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,6 +159,14 @@ 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
+
165
+ export function clearGitRefCache() {
166
+ gitRefCache.clear();
167
+ headCommitCache.clear();
168
+ }
169
+
162
170
  /**
163
171
  * Pin a repository file to a specific commit or HEAD.
164
172
  * Returns immutable git ref descriptor.
@@ -166,14 +174,34 @@ export function getBlob(sha256, amqRoot) {
166
174
  export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
167
175
  if (!repoRoot || !relativePath) return null;
168
176
 
177
+ const cleanRel = relativePath.replace(/^[/\\]+/, "");
178
+ const cacheKey = `${repoRoot}:${commit}:${cleanRel}`;
179
+ if (gitRefCache.has(cacheKey)) {
180
+ return gitRefCache.get(cacheKey);
181
+ }
182
+
169
183
  try {
170
- const cleanRel = relativePath.replace(/^[/\\]+/, "");
171
- // Resolve commit SHA
172
- const commitSha = execFileSync("git", ["rev-parse", commit], {
173
- cwd: repoRoot,
174
- encoding: "utf8",
175
- stdio: ["ignore", "pipe", "ignore"],
176
- }).trim();
184
+ let commitSha;
185
+ if (commit === "HEAD") {
186
+ const cachedHead = headCommitCache.get(repoRoot);
187
+ const now = Date.now();
188
+ if (cachedHead && now - cachedHead.at < 5000) {
189
+ commitSha = cachedHead.sha;
190
+ } else {
191
+ commitSha = execFileSync("git", ["rev-parse", "HEAD"], {
192
+ cwd: repoRoot,
193
+ encoding: "utf8",
194
+ stdio: ["ignore", "pipe", "ignore"],
195
+ }).trim();
196
+ headCommitCache.set(repoRoot, { sha: commitSha, at: now });
197
+ }
198
+ } else {
199
+ commitSha = execFileSync("git", ["rev-parse", commit], {
200
+ cwd: repoRoot,
201
+ encoding: "utf8",
202
+ stdio: ["ignore", "pipe", "ignore"],
203
+ }).trim();
204
+ }
177
205
 
178
206
  // Resolve git blob hash
179
207
  const blobSha = execFileSync("git", ["rev-parse", `${commitSha}:${cleanRel}`], {
@@ -194,7 +222,7 @@ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
194
222
  const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
195
223
  const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
196
224
 
197
- return {
225
+ const ref = {
198
226
  type: "git",
199
227
  commit: commitSha,
200
228
  shortCommit: commitSha.slice(0, 10),
@@ -209,7 +237,10 @@ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
209
237
  exists: true,
210
238
  url: `/api/git-file?commit=${commitSha}&path=${encodeURIComponent(cleanRel)}`,
211
239
  };
240
+ gitRefCache.set(cacheKey, ref);
241
+ return ref;
212
242
  } catch {
243
+ gitRefCache.set(cacheKey, null);
213
244
  return null;
214
245
  }
215
246
  }
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
  }
@@ -264,8 +233,8 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
264
233
  }
265
234
  }
266
235
 
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;
236
+ // Auto-scan body for referenced files (/tmp/..., paths with slashes, or image/log basenames)
237
+ 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
238
  const matches = body.match(regex) || [];
270
239
 
271
240
  for (const m of matches) {