herdr-plugin-amq 0.1.9 → 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/protocol.mjs +48 -16
- 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/protocol.mjs
CHANGED
|
@@ -83,17 +83,48 @@ function openNoFollow(filePath, flags, mode) {
|
|
|
83
83
|
return fs.openSync(filePath, flags | noFollow, mode);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function
|
|
86
|
+
function isDirectoryHandle(handle) {
|
|
87
|
+
return Boolean(handle && typeof handle === "object");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function closeDirectory(handle) {
|
|
91
|
+
if (!isDirectoryHandle(handle)) fs.closeSync(handle);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function descriptorPath(handle) {
|
|
95
|
+
if (isDirectoryHandle(handle)) return handle.path;
|
|
87
96
|
const base = process.platform === "darwin" ? "/dev/fd" : "/proc/self/fd";
|
|
88
|
-
return path.join(base, String(
|
|
97
|
+
return path.join(base, String(handle));
|
|
89
98
|
}
|
|
90
99
|
|
|
91
|
-
function openRelativeNoFollow(
|
|
100
|
+
function openRelativeNoFollow(dirHandle, name, flags, mode) {
|
|
92
101
|
if (!name || path.basename(name) !== name) throw new Error("Invalid relative Maildir name");
|
|
93
|
-
return openNoFollow(path.join(descriptorPath(
|
|
102
|
+
return openNoFollow(path.join(descriptorPath(dirHandle), name), flags, mode);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveDarwinDirectory(dirPath) {
|
|
106
|
+
const resolved = path.resolve(dirPath);
|
|
107
|
+
const parsed = path.parse(resolved);
|
|
108
|
+
const segments = resolved.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
109
|
+
const systemSymlinkRoots = new Set(["/private", "/tmp", "/var"]);
|
|
110
|
+
let current = parsed.root;
|
|
111
|
+
for (const segment of segments) {
|
|
112
|
+
current = path.join(current, segment);
|
|
113
|
+
const stat = fs.lstatSync(current);
|
|
114
|
+
if (stat.isSymbolicLink()) {
|
|
115
|
+
if (!systemSymlinkRoots.has(current)) throw new Error("Symlinked directory is not allowed");
|
|
116
|
+
current = fs.realpathSync(current);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!stat.isDirectory()) throw new Error("Maildir path is not a directory");
|
|
120
|
+
}
|
|
121
|
+
const realPath = fs.realpathSync(current);
|
|
122
|
+
if (!fs.statSync(realPath).isDirectory()) throw new Error("Maildir path is not a directory");
|
|
123
|
+
return realPath;
|
|
94
124
|
}
|
|
95
125
|
|
|
96
126
|
function openDirectorySecure(dirPath) {
|
|
127
|
+
if (process.platform === "darwin") return { path: resolveDarwinDirectory(dirPath) };
|
|
97
128
|
const resolved = path.resolve(dirPath);
|
|
98
129
|
const parsed = path.parse(resolved);
|
|
99
130
|
const segments = resolved.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
@@ -102,12 +133,12 @@ function openDirectorySecure(dirPath) {
|
|
|
102
133
|
try {
|
|
103
134
|
for (const segment of segments) {
|
|
104
135
|
const next = openRelativeNoFollow(current, segment, directoryFlag);
|
|
105
|
-
|
|
136
|
+
closeDirectory(current);
|
|
106
137
|
current = next;
|
|
107
138
|
}
|
|
108
139
|
return current;
|
|
109
140
|
} catch (error) {
|
|
110
|
-
|
|
141
|
+
closeDirectory(current);
|
|
111
142
|
throw error;
|
|
112
143
|
}
|
|
113
144
|
}
|
|
@@ -145,7 +176,7 @@ export function writeBoundedFileAtomic(filePath, content, maxBytes = MAX_MAILDIR
|
|
|
145
176
|
try {
|
|
146
177
|
writeFileAtomicBetweenDirectories(path.basename(filePath), dirFd, dirFd, content, maxBytes);
|
|
147
178
|
} finally {
|
|
148
|
-
|
|
179
|
+
closeDirectory(dirFd);
|
|
149
180
|
}
|
|
150
181
|
}
|
|
151
182
|
|
|
@@ -154,7 +185,7 @@ export function listMaildirMessageFiles(dirPath) {
|
|
|
154
185
|
try {
|
|
155
186
|
return fs.readdirSync(descriptorPath(dirFd));
|
|
156
187
|
} finally {
|
|
157
|
-
|
|
188
|
+
closeDirectory(dirFd);
|
|
158
189
|
}
|
|
159
190
|
}
|
|
160
191
|
|
|
@@ -176,8 +207,8 @@ export function moveMaildirMessage(amqRoot, handle, fromFolder, toFolder, fileNa
|
|
|
176
207
|
);
|
|
177
208
|
return path.join(toDir, fileName);
|
|
178
209
|
} finally {
|
|
179
|
-
|
|
180
|
-
if (toFd !== undefined)
|
|
210
|
+
closeDirectory(fromFd);
|
|
211
|
+
if (toFd !== undefined) closeDirectory(toFd);
|
|
181
212
|
}
|
|
182
213
|
}
|
|
183
214
|
|
|
@@ -206,7 +237,7 @@ export function readMaildirMessageFile(filePath, maxBytes = MAX_MAILDIR_FILE_BYT
|
|
|
206
237
|
return Buffer.concat(chunks, total).toString("utf8");
|
|
207
238
|
} finally {
|
|
208
239
|
if (fd !== undefined) fs.closeSync(fd);
|
|
209
|
-
|
|
240
|
+
closeDirectory(dirFd);
|
|
210
241
|
}
|
|
211
242
|
}
|
|
212
243
|
|
|
@@ -410,8 +441,8 @@ export function sendMaildirMessage(amqRoot, options = {}) {
|
|
|
410
441
|
newFd = openDirectorySecure(path.join(recipientDir, "inbox", "new"));
|
|
411
442
|
writeFileAtomicBetweenDirectories(`${msgId}.md`, tmpFd, newFd, fileText);
|
|
412
443
|
} finally {
|
|
413
|
-
|
|
414
|
-
if (newFd !== undefined)
|
|
444
|
+
closeDirectory(tmpFd);
|
|
445
|
+
if (newFd !== undefined) closeDirectory(newFd);
|
|
415
446
|
}
|
|
416
447
|
}
|
|
417
448
|
|
|
@@ -420,7 +451,7 @@ export function sendMaildirMessage(amqRoot, options = {}) {
|
|
|
420
451
|
try {
|
|
421
452
|
writeFileAtomicBetweenDirectories(`${msgId}.md`, sentFd, sentFd, fileText);
|
|
422
453
|
} finally {
|
|
423
|
-
|
|
454
|
+
closeDirectory(sentFd);
|
|
424
455
|
}
|
|
425
456
|
|
|
426
457
|
return {
|
|
@@ -449,8 +480,9 @@ export function markMaildirMessageRead(amqRoot, handle, msgId) {
|
|
|
449
480
|
if (!agentDir) return { ok: false, error: "Mailbox not found" };
|
|
450
481
|
const newPath = path.join(agentDir, "inbox", "new", `${msgId}.md`);
|
|
451
482
|
const curPath = path.join(agentDir, "inbox", "cur", `${msgId}.md`);
|
|
483
|
+
const logicalCurPath = path.join(amqRoot, "agents", handle, "inbox", "cur", `${msgId}.md`);
|
|
452
484
|
if (!fs.existsSync(newPath)) {
|
|
453
|
-
if (fs.existsSync(curPath)) return { ok: true, alreadyRead: true, id: msgId, filePath:
|
|
485
|
+
if (fs.existsSync(curPath)) return { ok: true, alreadyRead: true, id: msgId, filePath: logicalCurPath };
|
|
454
486
|
return { ok: false, error: "Message not found" };
|
|
455
487
|
}
|
|
456
488
|
|
|
@@ -464,7 +496,7 @@ export function markMaildirMessageRead(amqRoot, handle, msgId) {
|
|
|
464
496
|
|
|
465
497
|
const filePath = moveMaildirMessage(amqRoot, handle, "new", "cur", `${msgId}.md`);
|
|
466
498
|
if (!filePath) return { ok: false, error: "Message could not be marked read" };
|
|
467
|
-
return { ok: true, alreadyRead: false, id: msgId, filePath };
|
|
499
|
+
return { ok: true, alreadyRead: false, id: msgId, filePath: logicalCurPath };
|
|
468
500
|
} catch (error) {
|
|
469
501
|
return { ok: false, error: error.message };
|
|
470
502
|
}
|
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,
|