herdr-plugin-amq 0.1.10 → 0.1.11
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 +168 -26
- package/docs/architecture.md +3 -1
- package/docs/images/agmail-agent-activity.webp +0 -0
- package/docs/images/agmail-mail-thread.webp +0 -0
- package/docs/images/agmail-mobile-new-task.webp +0 -0
- package/docs/images/agmail-mobile-presence.webp +0 -0
- package/docs/images/agmail-persona-switcher.webp +0 -0
- package/docs/images/agmail-task-drawer.webp +0 -0
- package/docs/installation.md +29 -1
- package/docs/ui-screenshots.md +19 -1
- package/package.json +17 -2
- package/src/actions.mjs +3 -2
- package/src/blobs.mjs +9 -0
- package/src/bridge.mjs +19 -4
- package/src/fleet.mjs +1 -1
- package/src/herdr.mjs +19 -6
- package/src/server.mjs +154 -9
- package/src/store.mjs +19 -4
- package/src/web/app.js +367 -45
- package/src/web/index.html +45 -5
- package/src/web/style.css +153 -2
package/src/server.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { findAmqRoot, getAgentHandles } from "./config.mjs";
|
|
6
|
+
import { findAmqRoot, getAgentHandles, getHerdrBin } from "./config.mjs";
|
|
6
7
|
import { markMaildirMessageRead } from "./protocol.mjs";
|
|
7
8
|
import {
|
|
8
9
|
loadAllMessages,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
import {
|
|
38
39
|
getHerdrAgents,
|
|
39
40
|
getHerdrStatusMap,
|
|
41
|
+
normalizeHerdrStatus,
|
|
40
42
|
subscribeHerdrEvents,
|
|
41
43
|
isHerdrAvailable,
|
|
42
44
|
getSocketPath,
|
|
@@ -58,6 +60,55 @@ import {
|
|
|
58
60
|
|
|
59
61
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
60
62
|
const WEB_ROOT = path.join(__dirname, "web");
|
|
63
|
+
const HERDR_STATUS_EVENT_TYPES = new Set(["pane_agent_status_changed", "pane.agent_status_changed", "pane.updated", "agent.state_changed", "agent.updated"]);
|
|
64
|
+
const HERDR_REFRESH_EVENT_TYPES = new Set(["pane_agent_detected", "pane.created", "pane.closed", "workspace.created", "workspace.closed", "agent.created", "agent.closed"]);
|
|
65
|
+
|
|
66
|
+
// Strict CSP for the local dashboard. media-src 'self' covers same-origin
|
|
67
|
+
// <video> attachments streamed from /api/blob/* and /api/file.
|
|
68
|
+
export const CONTENT_SECURITY_POLICY =
|
|
69
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self'; connect-src 'self'; frame-ancestors 'none';";
|
|
70
|
+
|
|
71
|
+
export function isHerdrStatusEvent(type) {
|
|
72
|
+
return HERDR_STATUS_EVENT_TYPES.has(String(type || ""));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isHerdrRefreshEvent(type) {
|
|
76
|
+
return HERDR_REFRESH_EVENT_TYPES.has(String(type || ""));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function findHerdrEventHandle(cache, event) {
|
|
80
|
+
if (!cache || !event) return null;
|
|
81
|
+
const directHandle = String(event.name || event.handle || "").trim();
|
|
82
|
+
if (directHandle && cache.has(directHandle)) return directHandle;
|
|
83
|
+
const paneId = String(event.pane_id || "").trim();
|
|
84
|
+
if (!paneId) return null;
|
|
85
|
+
for (const [handle, activity] of cache.entries()) {
|
|
86
|
+
if (activity?.herdrPaneId === paneId) return handle;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function mergeHerdrStatusEvent(cache, event, observedAt = new Date().toISOString()) {
|
|
92
|
+
const handle = findHerdrEventHandle(cache, event);
|
|
93
|
+
if (!handle || !event?.agent_status) return null;
|
|
94
|
+
const current = cache.get(handle) || {};
|
|
95
|
+
const nextSeq = Number(event.state_change_seq);
|
|
96
|
+
const currentSeq = Number(current.herdrStateChangeSeq);
|
|
97
|
+
if (Number.isFinite(nextSeq) && Number.isFinite(currentSeq) && nextSeq < currentSeq) return null;
|
|
98
|
+
|
|
99
|
+
const next = {
|
|
100
|
+
...current,
|
|
101
|
+
herdrStatus: normalizeHerdrStatus(event.agent_status),
|
|
102
|
+
herdrObservedAt: observedAt,
|
|
103
|
+
};
|
|
104
|
+
if (Number.isFinite(nextSeq)) next.herdrStateChangeSeq = nextSeq;
|
|
105
|
+
if (event.state_labels && typeof event.state_labels === "object" && !Array.isArray(event.state_labels)) {
|
|
106
|
+
next.herdrStateLabels = { ...event.state_labels };
|
|
107
|
+
}
|
|
108
|
+
if (typeof event.title === "string" && event.title.trim()) next.herdrTitle = event.title.trim();
|
|
109
|
+
cache.set(handle, next);
|
|
110
|
+
return { handle, activity: next };
|
|
111
|
+
}
|
|
61
112
|
|
|
62
113
|
export function startWebServer({
|
|
63
114
|
port = 8505,
|
|
@@ -77,6 +128,8 @@ export function startWebServer({
|
|
|
77
128
|
let herdrStatusCache = new Map();
|
|
78
129
|
let herdrSubscription = null;
|
|
79
130
|
let herdrRefreshDebounce = null;
|
|
131
|
+
let herdrStatusFlushTimer = null;
|
|
132
|
+
const pendingHerdrStatusEvents = new Map();
|
|
80
133
|
|
|
81
134
|
function broadcastSSE(payload) {
|
|
82
135
|
const msg = `data: ${JSON.stringify(payload)}\n\n`;
|
|
@@ -100,6 +153,36 @@ export function startWebServer({
|
|
|
100
153
|
}, 80);
|
|
101
154
|
}
|
|
102
155
|
|
|
156
|
+
function queueHerdrStatusEvent(event) {
|
|
157
|
+
const handle = findHerdrEventHandle(herdrStatusCache, event);
|
|
158
|
+
if (!handle || !event?.agent_status) {
|
|
159
|
+
scheduleHerdrRefresh("status");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const current = herdrStatusCache.get(handle);
|
|
163
|
+
const nextStatus = normalizeHerdrStatus(event.agent_status);
|
|
164
|
+
if (current?.herdrStatus === nextStatus && !event.state_labels && !event.title) return;
|
|
165
|
+
pendingHerdrStatusEvents.set(handle, event);
|
|
166
|
+
if (herdrStatusFlushTimer) return;
|
|
167
|
+
herdrStatusFlushTimer = setTimeout(() => {
|
|
168
|
+
herdrStatusFlushTimer = null;
|
|
169
|
+
const pending = new Map(pendingHerdrStatusEvents);
|
|
170
|
+
pendingHerdrStatusEvents.clear();
|
|
171
|
+
for (const [pendingHandle, pendingEvent] of pending) {
|
|
172
|
+
const merged = mergeHerdrStatusEvent(herdrStatusCache, pendingEvent);
|
|
173
|
+
if (!merged || merged.handle !== pendingHandle) continue;
|
|
174
|
+
broadcastSSE({
|
|
175
|
+
type: "herdr_agent_update",
|
|
176
|
+
handle: merged.handle,
|
|
177
|
+
herdrStatus: merged.activity.herdrStatus,
|
|
178
|
+
stateLabels: merged.activity.herdrStateLabels || {},
|
|
179
|
+
title: merged.activity.herdrTitle || null,
|
|
180
|
+
at: merged.activity.herdrObservedAt,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}, 250);
|
|
184
|
+
}
|
|
185
|
+
|
|
103
186
|
let isClosing = false;
|
|
104
187
|
let herdrReconnectTimeout = null;
|
|
105
188
|
|
|
@@ -114,17 +197,17 @@ export function startWebServer({
|
|
|
114
197
|
},
|
|
115
198
|
onEvent: (event) => {
|
|
116
199
|
const type = String(event?.type || "");
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
type === "workspace.created" ||
|
|
121
|
-
type === "workspace.closed"
|
|
122
|
-
) {
|
|
200
|
+
if (isHerdrStatusEvent(type)) {
|
|
201
|
+
queueHerdrStatusEvent(event);
|
|
202
|
+
} else if (isHerdrRefreshEvent(type)) {
|
|
123
203
|
scheduleHerdrRefresh(type);
|
|
124
204
|
}
|
|
125
205
|
},
|
|
126
206
|
onDisconnect: () => {
|
|
127
207
|
herdrStatusCache = new Map();
|
|
208
|
+
pendingHerdrStatusEvents.clear();
|
|
209
|
+
if (herdrStatusFlushTimer) clearTimeout(herdrStatusFlushTimer);
|
|
210
|
+
herdrStatusFlushTimer = null;
|
|
128
211
|
broadcastSSE({ type: "herdr_agents_refresh", reason: "disconnected", at: new Date().toISOString() });
|
|
129
212
|
if (!isClosing) {
|
|
130
213
|
herdrReconnectTimeout = setTimeout(startHerdrSubscription, 5000);
|
|
@@ -136,6 +219,26 @@ export function startWebServer({
|
|
|
136
219
|
// Start Herdr integration (non-fatal if Herdr not running)
|
|
137
220
|
refreshHerdrCache().then(() => startHerdrSubscription()).catch(() => {});
|
|
138
221
|
|
|
222
|
+
// Safety poll: socket events alone left the cache 28+ minutes stale when
|
|
223
|
+
// status transitions arrived under an unsubscribed type. Re-poll on a
|
|
224
|
+
// timer and broadcast only when the status signature actually changed.
|
|
225
|
+
const HERDR_SAFETY_POLL_MS = parseInt(process.env.HERDR_SAFETY_POLL_MS || "20000", 10);
|
|
226
|
+
const herdrCacheSignature = () =>
|
|
227
|
+
[...herdrStatusCache].map(([h, v]) => `${h}:${v?.herdrStatus || "?"}`).sort().join("|");
|
|
228
|
+
let lastHerdrSignature = herdrCacheSignature();
|
|
229
|
+
const herdrSafetyTimer = setInterval(async () => {
|
|
230
|
+
if (isClosing) return;
|
|
231
|
+
try {
|
|
232
|
+
await refreshHerdrCache();
|
|
233
|
+
const sig = herdrCacheSignature();
|
|
234
|
+
if (sig !== lastHerdrSignature) {
|
|
235
|
+
lastHerdrSignature = sig;
|
|
236
|
+
broadcastSSE({ type: "herdr_agents_refresh", reason: "safety-poll", at: new Date().toISOString() });
|
|
237
|
+
}
|
|
238
|
+
} catch {}
|
|
239
|
+
}, HERDR_SAFETY_POLL_MS);
|
|
240
|
+
if (herdrSafetyTimer.unref) herdrSafetyTimer.unref();
|
|
241
|
+
|
|
139
242
|
let watchDebounce = null;
|
|
140
243
|
let watcher = null;
|
|
141
244
|
try {
|
|
@@ -177,7 +280,7 @@ export function startWebServer({
|
|
|
177
280
|
res.setHeader("X-Frame-Options", "DENY");
|
|
178
281
|
res.setHeader(
|
|
179
282
|
"Content-Security-Policy",
|
|
180
|
-
|
|
283
|
+
CONTENT_SECURITY_POLICY
|
|
181
284
|
);
|
|
182
285
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
183
286
|
|
|
@@ -306,11 +409,50 @@ export function startWebServer({
|
|
|
306
409
|
if (pathname === "/api/herdr-agents" && req.method === "GET") {
|
|
307
410
|
// Raw Herdr agent snapshot — all panes, not just named ones
|
|
308
411
|
const agents = await getHerdrAgents();
|
|
309
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
412
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
310
413
|
res.end(JSON.stringify(agents));
|
|
311
414
|
return;
|
|
312
415
|
}
|
|
313
416
|
|
|
417
|
+
if (pathname === "/api/panes" && req.method === "GET") {
|
|
418
|
+
// Live terminal tails per registered lane via `herdr agent read`.
|
|
419
|
+
// Bounded: clamped line count, per-pane timeout, truncated output.
|
|
420
|
+
const rawLines = parseInt(url.searchParams.get("lines") || "40", 10);
|
|
421
|
+
const lineCount = Number.isFinite(rawLines) ? Math.min(200, Math.max(5, rawLines)) : 40;
|
|
422
|
+
const only = (url.searchParams.get("handle") || "").trim();
|
|
423
|
+
const agents = loadAgentDirectory(amqRoot).filter((a) => !only || a.handle === only);
|
|
424
|
+
const panes = await Promise.all(
|
|
425
|
+
agents.map(
|
|
426
|
+
(a) =>
|
|
427
|
+
new Promise((resolve) => {
|
|
428
|
+
execFile(
|
|
429
|
+
getHerdrBin(),
|
|
430
|
+
["agent", "read", a.handle, "--lines", String(lineCount), "--format", "text"],
|
|
431
|
+
{ timeout: 8000, maxBuffer: 1024 * 1024 },
|
|
432
|
+
(err, stdout) => {
|
|
433
|
+
const clean = String(stdout || "")
|
|
434
|
+
// eslint-disable-next-line no-control-regex
|
|
435
|
+
.replace(/\u001b\[[0-9;]*m/g, "")
|
|
436
|
+
.replace(/\r/g, "");
|
|
437
|
+
// Stable error code only: raw spawn errors leak tmp paths
|
|
438
|
+
// and binary locations into the UI (and API responses).
|
|
439
|
+
resolve({
|
|
440
|
+
handle: a.handle,
|
|
441
|
+
ok: !err,
|
|
442
|
+
error: err ? "herdr-unavailable" : null,
|
|
443
|
+
output: clean.slice(-6000),
|
|
444
|
+
at: new Date().toISOString(),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
})
|
|
449
|
+
)
|
|
450
|
+
);
|
|
451
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
452
|
+
res.end(JSON.stringify(panes));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
314
456
|
if (pathname === "/api/agents" && req.method === "POST") {
|
|
315
457
|
const body = await parseJsonBody(req);
|
|
316
458
|
if (body.__error) {
|
|
@@ -813,6 +955,9 @@ export function startWebServer({
|
|
|
813
955
|
}
|
|
814
956
|
if (herdrReconnectTimeout) clearTimeout(herdrReconnectTimeout);
|
|
815
957
|
if (herdrRefreshDebounce) clearTimeout(herdrRefreshDebounce);
|
|
958
|
+
if (herdrStatusFlushTimer) clearTimeout(herdrStatusFlushTimer);
|
|
959
|
+
if (herdrSafetyTimer) clearInterval(herdrSafetyTimer);
|
|
960
|
+
pendingHerdrStatusEvents.clear();
|
|
816
961
|
if (watchDebounce) clearTimeout(watchDebounce);
|
|
817
962
|
if (herdrSubscription) {
|
|
818
963
|
try { herdrSubscription.close(); } catch {}
|
package/src/store.mjs
CHANGED
|
@@ -13,6 +13,7 @@ const PALETTE = [
|
|
|
13
13
|
"#9c27b0", "#009688", "#e91e63", "#3f51b5", "#00bcd4",
|
|
14
14
|
"#795548", "#607d8b", "#673ab7", "#2e7d32", "#c2185b"
|
|
15
15
|
];
|
|
16
|
+
const LEGACY_PLACEHOLDER_MODEL = "gemini 3.8 flash (high)";
|
|
16
17
|
|
|
17
18
|
export function getAgentColor(handle = "") {
|
|
18
19
|
let hash = 0;
|
|
@@ -205,6 +206,7 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
205
206
|
|
|
206
207
|
const ext = path.extname(clean).toLowerCase();
|
|
207
208
|
const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
|
|
209
|
+
const isVideo = [".mp4", ".m4v", ".webm"].includes(ext);
|
|
208
210
|
const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
|
|
209
211
|
|
|
210
212
|
// 1. Check disk resolution first (sub-millisecond)
|
|
@@ -221,6 +223,7 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
221
223
|
name: base,
|
|
222
224
|
ext,
|
|
223
225
|
isImage,
|
|
226
|
+
isVideo,
|
|
224
227
|
isLog,
|
|
225
228
|
exists: true,
|
|
226
229
|
sizeBytes,
|
|
@@ -236,6 +239,10 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
236
239
|
if (hashMatch) {
|
|
237
240
|
const stored = getBlob(hashMatch[1], amqRoot);
|
|
238
241
|
if (stored) {
|
|
242
|
+
const storedExt = (stored.ext || ext).toLowerCase();
|
|
243
|
+
const storedIsImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(storedExt);
|
|
244
|
+
const storedIsVideo = [".mp4", ".m4v", ".webm"].includes(storedExt);
|
|
245
|
+
const storedIsLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(storedExt);
|
|
239
246
|
attachments.push({
|
|
240
247
|
type: "blob",
|
|
241
248
|
sha256: stored.sha256,
|
|
@@ -243,8 +250,9 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
243
250
|
ext: stored.ext,
|
|
244
251
|
mime: stored.mime,
|
|
245
252
|
sizeBytes: stored.sizeBytes,
|
|
246
|
-
isImage,
|
|
247
|
-
|
|
253
|
+
isImage: storedIsImage,
|
|
254
|
+
isVideo: storedIsVideo,
|
|
255
|
+
isLog: storedIsLog,
|
|
248
256
|
exists: true,
|
|
249
257
|
sizeDisplay: formatFileSize(stored.sizeBytes),
|
|
250
258
|
url: `/api/blob/${stored.sha256}${stored.ext ? `?ext=${encodeURIComponent(stored.ext)}` : ""}`,
|
|
@@ -261,6 +269,7 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
261
269
|
name: base,
|
|
262
270
|
ext,
|
|
263
271
|
isImage,
|
|
272
|
+
isVideo,
|
|
264
273
|
isLog,
|
|
265
274
|
exists: false,
|
|
266
275
|
sizeBytes: 0,
|
|
@@ -822,7 +831,7 @@ export function loadAgentDirectory(amqRoot) {
|
|
|
822
831
|
const brief = briefs.get(h) || null;
|
|
823
832
|
const name = customProfile?.name || presence?.name || brief?.name || formatAgentTitle(h);
|
|
824
833
|
const role = customProfile?.role || (presence?.role && presence.role !== "Swarm Agent" ? presence.role : null) || brief?.role || brief?.description || (h === "user" ? "Human Operator" : "Swarm Agent");
|
|
825
|
-
const model = customProfile?.model || brief?.model
|
|
834
|
+
const model = normalizeModel(customProfile?.model) || normalizeModel(brief?.model);
|
|
826
835
|
const emoji = customProfile?.emoji || presence?.emoji || (h === "user" ? "👤" : h.slice(0, 1).toUpperCase());
|
|
827
836
|
const color = customProfile?.color || presence?.color || getAgentColor(h);
|
|
828
837
|
const worktree = customProfile?.worktree || null;
|
|
@@ -883,6 +892,12 @@ function normalizeText(value, fallback, maxLength) {
|
|
|
883
892
|
return normalized ? normalized.slice(0, maxLength) : fallback;
|
|
884
893
|
}
|
|
885
894
|
|
|
895
|
+
function normalizeModel(value, fallback = null) {
|
|
896
|
+
const normalized = normalizeText(value, fallback, 200);
|
|
897
|
+
if (typeof normalized !== "string") return normalized;
|
|
898
|
+
return normalized.toLowerCase() === LEGACY_PLACEHOLDER_MODEL ? null : normalized;
|
|
899
|
+
}
|
|
900
|
+
|
|
886
901
|
function normalizeMultiline(value, fallback, maxLength) {
|
|
887
902
|
if (typeof value !== "string") return fallback;
|
|
888
903
|
const normalized = value
|
|
@@ -980,7 +995,7 @@ function registerAgentLocked(amqRoot, agentDir, safeHandle, options) {
|
|
|
980
995
|
handle: safeHandle,
|
|
981
996
|
name: normalizeText(name, normalizeText(existingProfile?.name, defaultName, 120), 120),
|
|
982
997
|
role: normalizeText(role, normalizeText(existingProfile?.role, "Autonomous Specialist", 240), 240),
|
|
983
|
-
model:
|
|
998
|
+
model: normalizeModel(model, normalizeModel(existingProfile?.model)),
|
|
984
999
|
emoji: normalizeText(emoji, normalizeText(existingProfile?.emoji, defaultEmoji, 16), 16),
|
|
985
1000
|
color: normalizeText(color, normalizeText(existingProfile?.color, getAgentColor(safeHandle), 64), 64),
|
|
986
1001
|
worktree: normalizeText(worktree, normalizeText(existingProfile?.worktree, "", 1024), 1024) || null,
|