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/herdr.mjs ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * herdr.mjs — Herdr Socket API client for AGmail
3
+ *
4
+ * Connects to the running Herdr server via its Unix socket and provides:
5
+ * - getHerdrAgents() – one-shot snapshot of live agent records
6
+ * - subscribeAgentEvents() – long-lived event subscription, calls cb on each state change
7
+ * - getSocketPath() – resolve the Herdr socket path
8
+ *
9
+ * Protocol: JSON-RPC 2.0 over a Unix domain socket (newline-delimited JSON frames).
10
+ * See: https://herdr.dev/docs/socket-api/
11
+ */
12
+
13
+ import net from "node:net";
14
+ import { execSync } from "node:child_process";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+
18
+ // ─── Socket Path Resolution ──────────────────────────────────────────────────
19
+
20
+ export function getSocketPath() {
21
+ if (process.env.HERDR_SOCKET_PATH) return process.env.HERDR_SOCKET_PATH;
22
+ // Default: ~/.config/herdr/herdr.sock
23
+ return path.join(os.homedir(), ".config", "herdr", "herdr.sock");
24
+ }
25
+
26
+ // ─── Low-level request helper (one-shot connect, send, read response) ────────
27
+
28
+ function socketRequest(method, params = {}, timeoutMs = 8000) {
29
+ return new Promise((resolve, reject) => {
30
+ const socketPath = getSocketPath();
31
+ const id = `amq_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
32
+ const payload = JSON.stringify({ id, method, params }) + "\n";
33
+
34
+ const sock = net.createConnection(socketPath);
35
+ let buf = "";
36
+ let settled = false;
37
+
38
+ const timer = setTimeout(() => {
39
+ if (!settled) {
40
+ settled = true;
41
+ sock.destroy();
42
+ reject(new Error(`herdr socket request timed out (${method})`));
43
+ }
44
+ }, timeoutMs);
45
+
46
+ sock.on("connect", () => {
47
+ sock.write(payload);
48
+ });
49
+
50
+ sock.on("data", (chunk) => {
51
+ buf += chunk.toString();
52
+ const lines = buf.split("\n");
53
+ buf = lines.pop(); // keep incomplete last line
54
+ for (const line of lines) {
55
+ if (!line.trim()) continue;
56
+ try {
57
+ const msg = JSON.parse(line);
58
+ if (msg.id === id && !settled) {
59
+ settled = true;
60
+ clearTimeout(timer);
61
+ sock.destroy();
62
+ if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)));
63
+ else resolve(msg.result);
64
+ }
65
+ } catch {}
66
+ }
67
+ });
68
+
69
+ sock.on("error", (err) => {
70
+ if (!settled) {
71
+ settled = true;
72
+ clearTimeout(timer);
73
+ reject(err);
74
+ }
75
+ });
76
+
77
+ sock.on("close", () => {
78
+ if (!settled) {
79
+ settled = true;
80
+ clearTimeout(timer);
81
+ reject(new Error("herdr socket closed before response"));
82
+ }
83
+ });
84
+ });
85
+ }
86
+
87
+ // ─── Public API ──────────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Returns the list of agent records from the live Herdr session snapshot.
91
+ * Each record has: { name, agent, agent_status, pane_id, workspace_id, cwd, terminal_title, ... }
92
+ * Returns [] if Herdr is not running or socket unavailable.
93
+ */
94
+ export async function getHerdrAgents() {
95
+ try {
96
+ const result = await socketRequest("session.snapshot", {});
97
+ const snapshot = result?.snapshot || result || {};
98
+ return Array.isArray(snapshot.agents) ? snapshot.agents : [];
99
+ } catch {
100
+ return [];
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Returns a map of handle → { agent_status, pane_id, workspace_id, terminal_title }
106
+ * by matching `name` field from Herdr agent records to AMQ handles.
107
+ * Handles without a `name` in Herdr are skipped.
108
+ */
109
+ export async function getHerdrStatusMap() {
110
+ const agents = await getHerdrAgents();
111
+ const map = new Map();
112
+ for (const a of agents) {
113
+ const handle = a.name; // e.g. "ballistics", "testkit"
114
+ if (handle) {
115
+ map.set(handle, {
116
+ herdrStatus: a.agent_status || "unknown",
117
+ herdrPaneId: a.pane_id,
118
+ herdrWorkspaceId: a.workspace_id,
119
+ herdrTabId: a.tab_id,
120
+ herdrTitle: a.terminal_title_stripped || a.terminal_title,
121
+ interactiveReady: a.interactive_ready || false,
122
+ agentType: a.agent,
123
+ });
124
+ }
125
+ }
126
+ return map;
127
+ }
128
+
129
+ // ─── Long-lived event subscription ──────────────────────────────────────────
130
+
131
+ /**
132
+ * Opens a persistent connection to Herdr's events.subscribe endpoint.
133
+ * Calls onEvent(event) for each incoming event object.
134
+ * Calls onDisconnect() when the socket closes.
135
+ * Returns a { close() } handle.
136
+ *
137
+ * Relevant event types:
138
+ * agent.state_changed — { pane_id, name, agent_status, ... }
139
+ * pane.created / pane.closed
140
+ * workspace.created / workspace.closed
141
+ */
142
+ export function subscribeHerdrEvents({ onEvent, onDisconnect, onConnect } = {}) {
143
+ const socketPath = getSocketPath();
144
+ const subId = `amq_sub_${Date.now()}`;
145
+ const payload =
146
+ JSON.stringify({
147
+ id: subId,
148
+ method: "events.subscribe",
149
+ params: {},
150
+ }) + "\n";
151
+
152
+ let sock = null;
153
+ let closed = false;
154
+ let buf = "";
155
+
156
+ function connect() {
157
+ if (closed) return;
158
+ try {
159
+ sock = net.createConnection(socketPath);
160
+
161
+ sock.on("connect", () => {
162
+ buf = "";
163
+ sock.write(payload);
164
+ if (onConnect) onConnect();
165
+ });
166
+
167
+ sock.on("data", (chunk) => {
168
+ buf += chunk.toString();
169
+ const lines = buf.split("\n");
170
+ buf = lines.pop();
171
+ for (const line of lines) {
172
+ if (!line.trim()) continue;
173
+ try {
174
+ const msg = JSON.parse(line);
175
+ // Skip the ack for the subscribe call itself
176
+ if (msg.id === subId && msg.result) continue;
177
+ if (msg.method && msg.params && onEvent) {
178
+ onEvent({ type: msg.method, ...msg.params });
179
+ }
180
+ } catch {}
181
+ }
182
+ });
183
+
184
+ sock.on("error", () => {});
185
+ sock.on("close", () => {
186
+ if (!closed && onDisconnect) onDisconnect();
187
+ });
188
+ } catch {}
189
+ }
190
+
191
+ connect();
192
+
193
+ return {
194
+ close() {
195
+ closed = true;
196
+ if (sock) {
197
+ try { sock.destroy(); } catch {}
198
+ }
199
+ },
200
+ };
201
+ }
202
+
203
+ // ─── Herdr availability check ────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Returns true if the Herdr socket file exists and is reachable.
207
+ */
208
+ export async function isHerdrAvailable() {
209
+ try {
210
+ await socketRequest("session.snapshot", {}, 2000);
211
+ return true;
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./config.mjs";
2
+ export * from "./bridge.mjs";
3
+ export * from "./actions.mjs";
4
+ export * from "./panes.mjs";
@@ -0,0 +1,167 @@
1
+ export function escapeHtml(str) {
2
+ return String(str || "")
3
+ .replace(/&/g, "&")
4
+ .replace(/</g, "&lt;")
5
+ .replace(/>/g, "&gt;")
6
+ .replace(/"/g, "&quot;")
7
+ .replace(/'/g, "&#039;");
8
+ }
9
+
10
+ export function renderMarkdown(md) {
11
+ if (!md) return "";
12
+
13
+ // 1. Normalize line endings to LF
14
+ const text = String(md).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
15
+
16
+ // 2. Extract fenced code blocks line-by-line so comments (# ...) are never treated as headings
17
+ const lines = text.split("\n");
18
+ let inCodeBlock = false;
19
+ let codeFence = "";
20
+ let codeLang = "";
21
+ let codeLines = [];
22
+ const proseLines = [];
23
+ const codeBlocks = [];
24
+
25
+ for (let i = 0; i < lines.length; i++) {
26
+ const line = lines[i];
27
+ if (!inCodeBlock) {
28
+ const fenceMatch = line.match(/^[ \t]*(`{3,}|~{3,})([a-zA-Z0-9_+#.-]*)[^\n]*$/);
29
+ if (fenceMatch) {
30
+ inCodeBlock = true;
31
+ codeFence = fenceMatch[1];
32
+ codeLang = (fenceMatch[2] || "code").trim();
33
+ codeLines = [];
34
+ continue;
35
+ }
36
+ proseLines.push(line);
37
+ } else {
38
+ const closeMatch = line.match(/^[ \t]*(`{3,}|~{3,})[ \t]*$/);
39
+ if (closeMatch && closeMatch[1][0] === codeFence[0] && closeMatch[1].length >= codeFence.length) {
40
+ inCodeBlock = false;
41
+ const cleanCode = codeLines.join("\n");
42
+ const escapedCode = escapeHtml(cleanCode);
43
+ const blockHtml = `<div class="code-block-wrapper">
44
+ <div class="code-block-header">
45
+ <span class="code-lang">${escapeHtml(codeLang || "code")}</span>
46
+ <button class="copy-code-btn" onclick="copyCode(this)">Copy</button>
47
+ </div>
48
+ <pre><code>${escapedCode}</code></pre>
49
+ </div>`;
50
+ const idx = codeBlocks.length;
51
+ codeBlocks.push(blockHtml);
52
+ proseLines.push(`\x00AMQ_BLOCK_${idx}_\x00`);
53
+ continue;
54
+ }
55
+ codeLines.push(line);
56
+ }
57
+ }
58
+
59
+ // Handle unclosed fenced code block at end of input
60
+ if (inCodeBlock) {
61
+ const cleanCode = codeLines.join("\n");
62
+ const escapedCode = escapeHtml(cleanCode);
63
+ const blockHtml = `<div class="code-block-wrapper">
64
+ <div class="code-block-header">
65
+ <span class="code-lang">${escapeHtml(codeLang || "code")}</span>
66
+ <button class="copy-code-btn" onclick="copyCode(this)">Copy</button>
67
+ </div>
68
+ <pre><code>${escapedCode}</code></pre>
69
+ </div>`;
70
+ const idx = codeBlocks.length;
71
+ codeBlocks.push(blockHtml);
72
+ proseLines.push(`\x00AMQ_BLOCK_${idx}_\x00`);
73
+ }
74
+
75
+ let prose = proseLines.join("\n");
76
+
77
+ // 3. Extract inline code so inline snippets are not affected by prose formatting
78
+ const inlineCodes = [];
79
+ prose = prose.replace(/(`+)([\s\S]*?[^`])\1(?!`)/g, (match, fence, code) => {
80
+ const escaped = escapeHtml(code);
81
+ const idx = inlineCodes.length;
82
+ inlineCodes.push(`<code class="inline-code">${escaped}</code>`);
83
+ return `\x00AMQ_INLINE_${idx}_\x00`;
84
+ });
85
+
86
+ // 4. Escape remaining HTML in prose for injection protection
87
+ let html = escapeHtml(prose);
88
+
89
+ // 5. Blockquotes (handling both escaped &gt; and unescaped >)
90
+ html = html.replace(/^(?:&gt;|>)[ \t]?(.*$)/gm, '<blockquote class="md-quote">$1</blockquote>');
91
+ html = html.replace(/<\/blockquote>\n<blockquote class="md-quote">/g, "<br>");
92
+
93
+ // 6. Headings in prose (requiring whitespace after # to avoid false matches on tags or includes)
94
+ html = html.replace(/^######[ \t]+(.*$)/gm, '<h6 class="md-h6">$1</h6>');
95
+ html = html.replace(/^#####[ \t]+(.*$)/gm, '<h5 class="md-h5">$1</h5>');
96
+ html = html.replace(/^####[ \t]+(.*$)/gm, '<h4 class="md-h4">$1</h4>');
97
+ html = html.replace(/^###[ \t]+(.*$)/gm, '<h3 class="md-h3">$1</h3>');
98
+ html = html.replace(/^##[ \t]+(.*$)/gm, '<h2 class="md-h2">$1</h2>');
99
+ html = html.replace(/^#[ \t]+(.*$)/gm, '<h1 class="md-h1">$1</h1>');
100
+
101
+ // 7. Markdown tables in prose
102
+ const tableRegex = /((?:^[ \t]*\|?[^\n\r|]+(?:\|[^\n\r|]+)+\|?[ \t]*\n)(?:^[ \t]*\|?(?:[ \t]*:?-+:?[ \t]*\|)+(?:[ \t]*:?-+:?[ \t]*)\|?[ \t]*\n)(?:^[ \t]*\|?[^\n\r|]+(?:\|[^\n\r|]+)+\|?[ \t]*(?:\n|$))+)/gm;
103
+ html = html.replace(tableRegex, (match) => {
104
+ const tableLines = match.trim().split(/\n/).map((l) => l.trim()).filter(Boolean);
105
+ if (tableLines.length < 2) return match;
106
+ const parseRow = (line) => {
107
+ let clean = line;
108
+ if (clean.startsWith("|")) clean = clean.slice(1);
109
+ if (clean.endsWith("|")) clean = clean.slice(0, -1);
110
+ return clean.split("|").map((c) => c.trim());
111
+ };
112
+ const headerCols = parseRow(tableLines[0]);
113
+ const alignLine = parseRow(tableLines[1]);
114
+ const aligns = alignLine.map((col) => {
115
+ const left = col.startsWith(":");
116
+ const right = col.endsWith(":");
117
+ if (left && right) return "center";
118
+ if (right) return "right";
119
+ return "left";
120
+ });
121
+ let tableHtml = '<div class="table-container"><table class="md-table"><thead><tr>';
122
+ headerCols.forEach((col, idx) => {
123
+ const align = aligns[idx] || "left";
124
+ tableHtml += `<th style="text-align: ${align}">${col}</th>`;
125
+ });
126
+ tableHtml += "</tr></thead><tbody>";
127
+ for (let j = 2; j < tableLines.length; j++) {
128
+ const rowCols = parseRow(tableLines[j]);
129
+ tableHtml += "<tr>";
130
+ headerCols.forEach((_, idx) => {
131
+ const cell = rowCols[idx] !== undefined ? rowCols[idx] : "";
132
+ const align = aligns[idx] || "left";
133
+ tableHtml += `<td style="text-align: ${align}">${cell}</td>`;
134
+ });
135
+ tableHtml += "</tr>";
136
+ }
137
+ tableHtml += "</tbody></table></div>\n";
138
+ return tableHtml;
139
+ });
140
+
141
+ // 8. Bold, Italic, Strikethrough in prose
142
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
143
+ html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>");
144
+ html = html.replace(/~~([^~]+)~~/g, "<del>$1</del>");
145
+
146
+ // 9. Lists in prose
147
+ html = html.replace(/^([0-9]+\.|\([0-9]+\))[ \t]+(.*$)/gm, '<div class="md-list-item"><span class="md-list-num">$1</span> <span>$2</span></div>');
148
+ html = html.replace(/^[-*+][ \t]+(.*$)/gm, '<div class="md-bullet-item">• $1</div>');
149
+
150
+ // 10. Links in prose: [text](url)
151
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" class="md-link">$1</a>');
152
+
153
+ // 11. Paragraph breaks in prose
154
+ html = html.replace(/\n{2,}/g, '<div class="md-para-break"></div>');
155
+
156
+ // 12. Restore inline code
157
+ for (let k = 0; k < inlineCodes.length; k++) {
158
+ html = html.replace(`\x00AMQ_INLINE_${k}_\x00`, () => inlineCodes[k]);
159
+ }
160
+
161
+ // 13. Restore code blocks
162
+ for (let b = 0; b < codeBlocks.length; b++) {
163
+ html = html.replace(`\x00AMQ_BLOCK_${b}_\x00`, () => codeBlocks[b]);
164
+ }
165
+
166
+ return html;
167
+ }
package/src/panes.mjs ADDED
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { findAmqRoot, getAgentHandles, execCmd } from "./config.mjs";
5
+ import { listInbox } from "./bridge.mjs";
6
+
7
+ import { startWebServer } from "./server.mjs";
8
+
9
+ export function launchDashboardPane() {
10
+ const amqRoot = findAmqRoot();
11
+ console.log("\x1b[1m✉️ AGmail Dashboard (Pure JS)\x1b[0m\n");
12
+
13
+ if (!amqRoot) {
14
+ console.error("❌ No .agent-mail directory found in current workspace.");
15
+ process.exit(1);
16
+ }
17
+
18
+ const port = parseInt(process.env.AGMAIL_PORT || "8505", 10);
19
+ const server = startWebServer({ port, amqRoot });
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 {}
26
+
27
+ console.log("\nPress Ctrl+C to stop the dashboard server.");
28
+ }
29
+
30
+ export function launchInboxPeekPane() {
31
+ const amqRoot = findAmqRoot();
32
+ if (!amqRoot) {
33
+ console.log("❌ No .agent-mail directory found.");
34
+ process.exit(1);
35
+ }
36
+
37
+ renderInboxSummary(amqRoot);
38
+ }
39
+
40
+ export function renderInboxSummary(amqRoot) {
41
+ const handles = getAgentHandles(amqRoot);
42
+ console.log(`\x1b[1m📫 AMQ Mailbox Overview\x1b[0m: \x1b[36m${amqRoot}\x1b[0m\n`);
43
+
44
+ let totalMsgs = 0;
45
+ for (const h of handles) {
46
+ const msgs = listInbox(amqRoot, h);
47
+ if (!msgs.length) continue;
48
+ totalMsgs += msgs.length;
49
+
50
+ console.log(`\x1b[1m\x1b[33m📥 ${h}\x1b[0m (\x1b[32m${msgs.length} unread\x1b[0m)`);
51
+ for (const m of msgs.slice(0, 5)) {
52
+ const from = m.from || "unknown";
53
+ const subject = m.subject || "(no subject)";
54
+ const id = m.id ? `[${m.id.slice(0, 8)}]` : "";
55
+ console.log(` • \x1b[90m${id}\x1b[0m \x1b[1m${from}\x1b[0m: ${subject}`);
56
+ }
57
+ if (msgs.length > 5) {
58
+ console.log(` \x1b[90m... and ${msgs.length - 5} more\x1b[0m`);
59
+ }
60
+ console.log("");
61
+ }
62
+
63
+ if (totalMsgs === 0) {
64
+ console.log("✨ All inboxes are clear! No unread transmissions.");
65
+ }
66
+
67
+ console.log("\x1b[90mPress Enter or Escape to close.\x1b[0m");
68
+ }