herdr-plugin-amq 0.1.7 → 0.1.9
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 +35 -265
- package/bin/herdr-amq.mjs +6 -0
- package/docs/architecture.md +50 -0
- package/docs/cli-and-workflows.md +72 -0
- package/docs/images/agmail-agent-activity.webp +0 -0
- package/docs/images/agmail-mobile-new-task.webp +0 -0
- package/docs/images/agmail-task-drawer.webp +0 -0
- package/docs/installation.md +70 -0
- package/docs/security-and-testing.md +33 -0
- package/docs/ui-screenshots.md +21 -0
- package/herdr-plugin.toml +7 -1
- package/package.json +11 -5
- package/skills/herdr-amq/SKILL.md +12 -6
- package/src/actions.mjs +149 -12
- package/src/board.mjs +144 -6
- package/src/bridge.mjs +249 -34
- package/src/config.mjs +36 -2
- package/src/fleet.mjs +282 -111
- package/src/herdr.mjs +68 -14
- package/src/index.mjs +1 -0
- package/src/protocol.mjs +315 -67
- package/src/runtime-models.mjs +118 -0
- package/src/server.mjs +81 -37
- package/src/store.mjs +219 -34
- package/src/templates.mjs +118 -0
- package/src/web/app.js +487 -105
- package/src/web/index.html +130 -11
- package/src/web/style.css +1022 -39
package/src/server.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { findAmqRoot, getAgentHandles } from "./config.mjs";
|
|
6
|
+
import { markMaildirMessageRead } from "./protocol.mjs";
|
|
6
7
|
import {
|
|
7
8
|
loadAllMessages,
|
|
8
9
|
loadThreads,
|
|
@@ -75,6 +76,7 @@ export function startWebServer({
|
|
|
75
76
|
// Map<handle, { herdrStatus, herdrPaneId, herdrWorkspaceId, ... }>
|
|
76
77
|
let herdrStatusCache = new Map();
|
|
77
78
|
let herdrSubscription = null;
|
|
79
|
+
let herdrRefreshDebounce = null;
|
|
78
80
|
|
|
79
81
|
function broadcastSSE(payload) {
|
|
80
82
|
const msg = `data: ${JSON.stringify(payload)}\n\n`;
|
|
@@ -89,6 +91,15 @@ export function startWebServer({
|
|
|
89
91
|
} catch {}
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
function scheduleHerdrRefresh(reason = "event") {
|
|
95
|
+
if (isClosing) return;
|
|
96
|
+
clearTimeout(herdrRefreshDebounce);
|
|
97
|
+
herdrRefreshDebounce = setTimeout(async () => {
|
|
98
|
+
await refreshHerdrCache();
|
|
99
|
+
broadcastSSE({ type: "herdr_agents_refresh", reason, at: new Date().toISOString() });
|
|
100
|
+
}, 80);
|
|
101
|
+
}
|
|
102
|
+
|
|
92
103
|
let isClosing = false;
|
|
93
104
|
let herdrReconnectTimeout = null;
|
|
94
105
|
|
|
@@ -99,42 +110,22 @@ export function startWebServer({
|
|
|
99
110
|
}
|
|
100
111
|
herdrSubscription = subscribeHerdrEvents({
|
|
101
112
|
onConnect: () => {
|
|
102
|
-
|
|
103
|
-
refreshHerdrCache();
|
|
113
|
+
scheduleHerdrRefresh("connected");
|
|
104
114
|
},
|
|
105
115
|
onEvent: (event) => {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
herdrStatus: event.agent_status || existing.herdrStatus,
|
|
115
|
-
herdrPaneId: event.pane_id || existing.herdrPaneId,
|
|
116
|
-
herdrWorkspaceId: event.workspace_id || existing.herdrWorkspaceId,
|
|
117
|
-
herdrTabId: event.tab_id || existing.herdrTabId,
|
|
118
|
-
herdrTitle: event.terminal_title_stripped || event.terminal_title || existing.herdrTitle,
|
|
119
|
-
interactiveReady: event.interactive_ready ?? existing.interactiveReady,
|
|
120
|
-
});
|
|
121
|
-
broadcastSSE({
|
|
122
|
-
type: "herdr_agent_update",
|
|
123
|
-
handle,
|
|
124
|
-
herdrStatus: event.agent_status,
|
|
125
|
-
paneId: event.pane_id,
|
|
126
|
-
at: new Date().toISOString(),
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
// Workspace/pane lifecycle — do a full agent refresh
|
|
131
|
-
if (t === "workspace.created" || t === "workspace.closed" ||
|
|
132
|
-
t === "pane.created" || t === "pane.closed") {
|
|
133
|
-
refreshHerdrCache();
|
|
116
|
+
const type = String(event?.type || "");
|
|
117
|
+
if (
|
|
118
|
+
type.startsWith("pane.") ||
|
|
119
|
+
type.startsWith("agent.") ||
|
|
120
|
+
type === "workspace.created" ||
|
|
121
|
+
type === "workspace.closed"
|
|
122
|
+
) {
|
|
123
|
+
scheduleHerdrRefresh(type);
|
|
134
124
|
}
|
|
135
125
|
},
|
|
136
126
|
onDisconnect: () => {
|
|
137
|
-
|
|
127
|
+
herdrStatusCache = new Map();
|
|
128
|
+
broadcastSSE({ type: "herdr_agents_refresh", reason: "disconnected", at: new Date().toISOString() });
|
|
138
129
|
if (!isClosing) {
|
|
139
130
|
herdrReconnectTimeout = setTimeout(startHerdrSubscription, 5000);
|
|
140
131
|
}
|
|
@@ -285,9 +276,25 @@ export function startWebServer({
|
|
|
285
276
|
herdrWorkspaceId: h.herdrWorkspaceId,
|
|
286
277
|
herdrTabId: h.herdrTabId,
|
|
287
278
|
herdrTitle: h.herdrTitle,
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
279
|
+
herdrObservedAt: h.herdrObservedAt,
|
|
280
|
+
interactiveReady: h.interactiveReady,
|
|
281
|
+
agentType: h.agentType,
|
|
282
|
+
runtimeModel: h.herdrModel || null,
|
|
283
|
+
modelSource: h.herdrModelSource || null,
|
|
284
|
+
herdrActivity: {
|
|
285
|
+
status: h.herdrStatus,
|
|
286
|
+
title: h.herdrMetadataTitle || h.herdrTitle,
|
|
287
|
+
terminalTitle: h.herdrTerminalTitle,
|
|
288
|
+
metadataTitle: h.herdrMetadataTitle,
|
|
289
|
+
stateLabels: h.herdrStateLabels || {},
|
|
290
|
+
tokens: h.herdrTokens || [],
|
|
291
|
+
stateChangeSeq: h.herdrStateChangeSeq,
|
|
292
|
+
observedAt: h.herdrObservedAt,
|
|
293
|
+
focused: h.herdrFocused,
|
|
294
|
+
launchPending: h.herdrLaunchPending,
|
|
295
|
+
model: h.herdrModel || null,
|
|
296
|
+
modelSource: h.herdrModelSource || null,
|
|
297
|
+
},
|
|
291
298
|
status: h.herdrStatus !== "unknown" ? h.herdrStatus : (a.status || "offline"),
|
|
292
299
|
};
|
|
293
300
|
});
|
|
@@ -306,6 +313,11 @@ export function startWebServer({
|
|
|
306
313
|
|
|
307
314
|
if (pathname === "/api/agents" && req.method === "POST") {
|
|
308
315
|
const body = await parseJsonBody(req);
|
|
316
|
+
if (body.__error) {
|
|
317
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
318
|
+
res.end(JSON.stringify({ ok: false, error: body.__error }));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
309
321
|
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
310
322
|
// Workspaces are the default under the hood: automatically isolate agent in .worktrees/<handle>
|
|
311
323
|
const worktreeResult = ensureAgentWorktree(repoRoot, body.handle, body.branch);
|
|
@@ -314,7 +326,7 @@ export function startWebServer({
|
|
|
314
326
|
worktree: worktreeResult?.ok ? worktreeResult.path : body.worktree,
|
|
315
327
|
});
|
|
316
328
|
// Refresh Herdr cache after registration
|
|
317
|
-
|
|
329
|
+
scheduleHerdrRefresh("agent_registered");
|
|
318
330
|
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
319
331
|
res.end(JSON.stringify({ ...result, worktreeResult }));
|
|
320
332
|
return;
|
|
@@ -459,6 +471,24 @@ export function startWebServer({
|
|
|
459
471
|
}
|
|
460
472
|
|
|
461
473
|
|
|
474
|
+
if (pathname.startsWith("/api/messages/") && pathname.endsWith("/read") && req.method === "POST") {
|
|
475
|
+
const messageId = decodeURIComponent(pathname.slice("/api/messages/".length, -"/read".length));
|
|
476
|
+
const account = url.searchParams.get("account") || "user";
|
|
477
|
+
if (account === "all" || !/^[A-Za-z0-9_-]{1,128}$/.test(account)) {
|
|
478
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
479
|
+
res.end(JSON.stringify({ ok: false, error: "A concrete mailbox account is required" }));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const result = markMaildirMessageRead(amqRoot, account, messageId);
|
|
483
|
+
if (result.ok) {
|
|
484
|
+
invalidateMessageCache();
|
|
485
|
+
broadcastSSE({ type: "mail_update", reason: "message_read", account, messageId, at: new Date().toISOString() });
|
|
486
|
+
}
|
|
487
|
+
res.writeHead(result.ok ? 200 : 404, { "Content-Type": "application/json" });
|
|
488
|
+
res.end(JSON.stringify(result));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
462
492
|
if (pathname === "/api/messages" && req.method === "GET") {
|
|
463
493
|
const account = url.searchParams.get("account") || "all";
|
|
464
494
|
const folder = url.searchParams.get("folder") || "inbox";
|
|
@@ -718,8 +748,11 @@ export function startWebServer({
|
|
|
718
748
|
? "text/css; charset=utf-8"
|
|
719
749
|
: "application/javascript; charset=utf-8";
|
|
720
750
|
|
|
721
|
-
|
|
722
|
-
|
|
751
|
+
res.writeHead(200, {
|
|
752
|
+
"Content-Type": mime,
|
|
753
|
+
"Cache-Control": "no-store, max-age=0",
|
|
754
|
+
});
|
|
755
|
+
fs.createReadStream(filePath).pipe(res);
|
|
723
756
|
} else {
|
|
724
757
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
725
758
|
res.end("File not found");
|
|
@@ -779,6 +812,7 @@ export function startWebServer({
|
|
|
779
812
|
server6 = null;
|
|
780
813
|
}
|
|
781
814
|
if (herdrReconnectTimeout) clearTimeout(herdrReconnectTimeout);
|
|
815
|
+
if (herdrRefreshDebounce) clearTimeout(herdrRefreshDebounce);
|
|
782
816
|
if (watchDebounce) clearTimeout(watchDebounce);
|
|
783
817
|
if (herdrSubscription) {
|
|
784
818
|
try { herdrSubscription.close(); } catch {}
|
|
@@ -801,10 +835,20 @@ export function startWebServer({
|
|
|
801
835
|
function parseJsonBody(req) {
|
|
802
836
|
return new Promise((resolve) => {
|
|
803
837
|
let acc = "";
|
|
838
|
+
let tooLarge = false;
|
|
804
839
|
req.on("data", (chunk) => {
|
|
840
|
+
if (tooLarge) return;
|
|
805
841
|
acc += chunk;
|
|
842
|
+
if (Buffer.byteLength(acc) > 1024 * 1024) {
|
|
843
|
+
acc = "";
|
|
844
|
+
tooLarge = true;
|
|
845
|
+
}
|
|
806
846
|
});
|
|
807
847
|
req.on("end", () => {
|
|
848
|
+
if (tooLarge) {
|
|
849
|
+
resolve({ __error: "request body too large" });
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
808
852
|
try {
|
|
809
853
|
resolve(JSON.parse(acc));
|
|
810
854
|
} catch {
|
package/src/store.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import crypto from "node:crypto";
|
|
|
5
5
|
import { execCmd, getHerdrBin, getAgentHandles, getRepoRootFromAmq } from "./config.mjs";
|
|
6
6
|
import { scanAgentBriefs, getAgentBrief, saveAgentBrief } from "./briefs.mjs";
|
|
7
7
|
import { ingestAttachment } from "./blobs.mjs";
|
|
8
|
-
import { sendMaildirMessage, replyMaildirMessage, drainMaildir } from "./protocol.mjs";
|
|
8
|
+
import { sendMaildirMessage, replyMaildirMessage, drainMaildir, ensureAgentMailbox, findMessageForRecipient, isSafeMailIdentifier, readMaildirMessageFile, writeBoundedFileAtomic } from "./protocol.mjs";
|
|
9
|
+
import { renderLocalTemplate } from "./templates.mjs";
|
|
9
10
|
|
|
10
11
|
const PALETTE = [
|
|
11
12
|
"#1a73e8", "#ea4335", "#fbbc05", "#34a853", "#ff6d00",
|
|
@@ -856,58 +857,242 @@ export function loadAgentDirectory(amqRoot) {
|
|
|
856
857
|
return list;
|
|
857
858
|
}
|
|
858
859
|
|
|
860
|
+
function readAgentProfile(profilePath) {
|
|
861
|
+
try {
|
|
862
|
+
const content = readMaildirMessageFile(profilePath, 1024 * 1024);
|
|
863
|
+
if (content === null) return null;
|
|
864
|
+
const parsed = JSON.parse(content);
|
|
865
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
866
|
+
} catch {
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function writeAgentProfile(agentDir, profileData) {
|
|
872
|
+
const profilePath = path.join(agentDir, "profile.json");
|
|
873
|
+
writeBoundedFileAtomic(profilePath, JSON.stringify(profileData, null, 2), 1024 * 1024);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function normalizeText(value, fallback, maxLength) {
|
|
877
|
+
if (typeof value !== "string") return fallback;
|
|
878
|
+
const normalized = value
|
|
879
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
880
|
+
.replace(/`/g, "'")
|
|
881
|
+
.replace(/\s+/g, " ")
|
|
882
|
+
.trim();
|
|
883
|
+
return normalized ? normalized.slice(0, maxLength) : fallback;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function normalizeMultiline(value, fallback, maxLength) {
|
|
887
|
+
if (typeof value !== "string") return fallback;
|
|
888
|
+
const normalized = value
|
|
889
|
+
.replace(/\0/g, "")
|
|
890
|
+
.replace(/\r\n/g, "\n")
|
|
891
|
+
.replace(/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
|
|
892
|
+
.trim();
|
|
893
|
+
return normalized ? normalized.slice(0, maxLength) : fallback;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function withAgentRegistrationLock(agentDir, operation) {
|
|
897
|
+
const lockDir = path.join(agentDir, ".registration.lock");
|
|
898
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
899
|
+
const deadline = Date.now() + 5000;
|
|
900
|
+
|
|
901
|
+
while (true) {
|
|
902
|
+
try {
|
|
903
|
+
fs.mkdirSync(lockDir);
|
|
904
|
+
break;
|
|
905
|
+
} catch (error) {
|
|
906
|
+
if (error.code !== "EEXIST") throw error;
|
|
907
|
+
try {
|
|
908
|
+
const stat = fs.statSync(lockDir);
|
|
909
|
+
if (Date.now() - stat.mtimeMs > 30000) fs.rmSync(lockDir, { recursive: true, force: true });
|
|
910
|
+
} catch {}
|
|
911
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for agent registration lock");
|
|
912
|
+
Atomics.wait(sleeper, 0, 0, 25);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
try {
|
|
917
|
+
return operation();
|
|
918
|
+
} finally {
|
|
919
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function defaultWelcomeBody(handle, name) {
|
|
924
|
+
return `Welcome to AMQ.\nAgent data: name=${JSON.stringify(name)}; handle=${JSON.stringify(handle)}.\nRun herdr-amq mail drain --me ${handle} --include-body to read messages. No reply is required.`.slice(0, 2048);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function selectProfileFields(profile) {
|
|
928
|
+
if (!profile || typeof profile !== "object") return {};
|
|
929
|
+
const selected = {};
|
|
930
|
+
for (const key of ["name", "role", "model", "emoji", "color", "worktree", "prompt", "createdAt", "registrationId"]) {
|
|
931
|
+
if (Object.prototype.hasOwnProperty.call(profile, key)) selected[key] = profile[key];
|
|
932
|
+
}
|
|
933
|
+
if (profile.welcome && typeof profile.welcome === "object" && !Array.isArray(profile.welcome)) {
|
|
934
|
+
selected.welcome = {};
|
|
935
|
+
for (const key of ["status", "messageId", "sentAt", "templatePath", "templateSha256", "source", "lastError"]) {
|
|
936
|
+
if (Object.prototype.hasOwnProperty.call(profile.welcome, key)) selected.welcome[key] = profile.welcome[key];
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return selected;
|
|
940
|
+
}
|
|
941
|
+
|
|
859
942
|
/**
|
|
860
943
|
* Register a new agent or update an existing agent profile with model configuration and prompt
|
|
861
944
|
*/
|
|
862
|
-
export function registerAgent(amqRoot,
|
|
863
|
-
if (!amqRoot || !fs.existsSync(amqRoot)) {
|
|
864
|
-
return { ok: false, error: "Invalid AMQ root" };
|
|
865
|
-
}
|
|
945
|
+
export function registerAgent(amqRoot, options = {}) {
|
|
946
|
+
if (!amqRoot || !fs.existsSync(amqRoot)) return { ok: false, error: "Invalid AMQ root" };
|
|
866
947
|
|
|
867
|
-
const safeHandle = (handle || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
868
|
-
if (!safeHandle) {
|
|
869
|
-
return { ok: false, error: "Invalid agent handle" };
|
|
870
|
-
}
|
|
948
|
+
const safeHandle = (options.handle || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
949
|
+
if (!isSafeMailIdentifier(safeHandle, 128)) return { ok: false, error: "Invalid agent handle" };
|
|
871
950
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
fs.mkdirSync(path.join(agentDir, "inbox", "tmp"), { recursive: true });
|
|
878
|
-
fs.mkdirSync(path.join(agentDir, "outbox", "sent"), { recursive: true });
|
|
879
|
-
fs.mkdirSync(path.join(agentDir, "outbox", "tmp"), { recursive: true });
|
|
880
|
-
fs.mkdirSync(path.join(agentDir, "receipts"), { recursive: true });
|
|
951
|
+
try {
|
|
952
|
+
const agentDir = ensureAgentMailbox(amqRoot, safeHandle);
|
|
953
|
+
return withAgentRegistrationLock(agentDir, () => registerAgentLocked(amqRoot, agentDir, safeHandle, options));
|
|
954
|
+
} catch (error) {
|
|
955
|
+
return { ok: false, error: error.message };
|
|
881
956
|
}
|
|
957
|
+
}
|
|
882
958
|
|
|
883
|
-
|
|
959
|
+
function registerAgentLocked(amqRoot, agentDir, safeHandle, options) {
|
|
960
|
+
const { name, role, model, emoji, color, worktree, prompt, brief, syncDisk = true } = options;
|
|
961
|
+
const profilePath = path.join(agentDir, "profile.json");
|
|
962
|
+
const existingProfile = readAgentProfile(profilePath);
|
|
963
|
+
const now = new Date().toISOString();
|
|
964
|
+
const registrationId = isSafeMailIdentifier(existingProfile?.registrationId, 128)
|
|
965
|
+
? existingProfile.registrationId
|
|
966
|
+
: crypto.randomUUID();
|
|
967
|
+
const shouldDeliverWelcome = !existingProfile || existingProfile?.welcome?.status === "pending";
|
|
968
|
+
const welcomeMessageId = isSafeMailIdentifier(existingProfile?.welcome?.messageId)
|
|
969
|
+
? existingProfile.welcome.messageId
|
|
970
|
+
: `welcome-${registrationId}`;
|
|
971
|
+
const defaultName = formatAgentTitle(safeHandle);
|
|
972
|
+
const defaultEmoji = safeHandle === "user" ? "👤" : safeHandle.slice(0, 1).toUpperCase();
|
|
973
|
+
const promptContent = normalizeMultiline(
|
|
974
|
+
prompt ?? brief,
|
|
975
|
+
normalizeMultiline(existingProfile?.prompt, "", 256 * 1024),
|
|
976
|
+
256 * 1024
|
|
977
|
+
);
|
|
884
978
|
const profileData = {
|
|
979
|
+
...selectProfileFields(existingProfile),
|
|
885
980
|
handle: safeHandle,
|
|
886
|
-
name: name
|
|
887
|
-
role: role
|
|
888
|
-
model: model
|
|
889
|
-
emoji: emoji
|
|
890
|
-
color: color
|
|
891
|
-
worktree: worktree || null,
|
|
981
|
+
name: normalizeText(name, normalizeText(existingProfile?.name, defaultName, 120), 120),
|
|
982
|
+
role: normalizeText(role, normalizeText(existingProfile?.role, "Autonomous Specialist", 240), 240),
|
|
983
|
+
model: normalizeText(model, normalizeText(existingProfile?.model, "claude-3-7-sonnet", 200), 200),
|
|
984
|
+
emoji: normalizeText(emoji, normalizeText(existingProfile?.emoji, defaultEmoji, 16), 16),
|
|
985
|
+
color: normalizeText(color, normalizeText(existingProfile?.color, getAgentColor(safeHandle), 64), 64),
|
|
986
|
+
worktree: normalizeText(worktree, normalizeText(existingProfile?.worktree, "", 1024), 1024) || null,
|
|
892
987
|
prompt: promptContent,
|
|
893
|
-
|
|
988
|
+
createdAt: existingProfile?.createdAt || now,
|
|
989
|
+
registrationId,
|
|
990
|
+
updatedAt: now,
|
|
894
991
|
};
|
|
895
992
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
993
|
+
if (shouldDeliverWelcome) {
|
|
994
|
+
profileData.welcome = {
|
|
995
|
+
status: "pending",
|
|
996
|
+
messageId: welcomeMessageId,
|
|
997
|
+
...(typeof existingProfile?.welcome?.lastError === "string"
|
|
998
|
+
? { lastError: normalizeText(existingProfile.welcome.lastError, "delivery pending", 512) }
|
|
999
|
+
: {}),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
writeAgentProfile(agentDir, profileData);
|
|
901
1003
|
|
|
902
|
-
// Sync to disk brief file if prompt is provided
|
|
903
1004
|
if (syncDisk && promptContent) {
|
|
904
1005
|
try {
|
|
905
1006
|
const repoRoot = getRepoRootFromAmq(amqRoot);
|
|
906
|
-
saveAgentBrief(repoRoot, safeHandle, {
|
|
1007
|
+
saveAgentBrief(repoRoot, safeHandle, {
|
|
1008
|
+
description: profileData.role,
|
|
1009
|
+
prompt: promptContent,
|
|
1010
|
+
model: profileData.model,
|
|
1011
|
+
role: profileData.role,
|
|
1012
|
+
});
|
|
907
1013
|
} catch {}
|
|
908
1014
|
}
|
|
909
1015
|
|
|
910
|
-
return { ok: true, agent: profileData };
|
|
1016
|
+
if (!shouldDeliverWelcome) return { ok: true, agent: profileData, welcome: { ok: true, skipped: true } };
|
|
1017
|
+
|
|
1018
|
+
const rendered = renderLocalTemplate(
|
|
1019
|
+
amqRoot,
|
|
1020
|
+
"welcome",
|
|
1021
|
+
{
|
|
1022
|
+
agent: {
|
|
1023
|
+
handle: safeHandle,
|
|
1024
|
+
name: profileData.name,
|
|
1025
|
+
role: profileData.role,
|
|
1026
|
+
model: profileData.model,
|
|
1027
|
+
emoji: profileData.emoji,
|
|
1028
|
+
color: profileData.color,
|
|
1029
|
+
worktree: profileData.worktree || "",
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
defaultWelcomeBody(safeHandle, profileData.name)
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
let deliveredMessage = findMessageForRecipient(amqRoot, safeHandle, welcomeMessageId);
|
|
1036
|
+
let deliveryError = null;
|
|
1037
|
+
if (!deliveredMessage) {
|
|
1038
|
+
try {
|
|
1039
|
+
sendMaildirMessage(amqRoot, {
|
|
1040
|
+
id: welcomeMessageId,
|
|
1041
|
+
from: "coordinator",
|
|
1042
|
+
to: [safeHandle],
|
|
1043
|
+
subject: `Welcome to AMQ, ${safeHandle}`,
|
|
1044
|
+
thread: `welcome/${safeHandle}`,
|
|
1045
|
+
body: rendered.text,
|
|
1046
|
+
priority: "normal",
|
|
1047
|
+
kind: "status",
|
|
1048
|
+
labels: ["welcome", "registration"],
|
|
1049
|
+
context: {
|
|
1050
|
+
template: "welcome",
|
|
1051
|
+
templateSource: rendered.source,
|
|
1052
|
+
templateSha256: rendered.sha256,
|
|
1053
|
+
registrationId,
|
|
1054
|
+
},
|
|
1055
|
+
created: now,
|
|
1056
|
+
});
|
|
1057
|
+
deliveredMessage = findMessageForRecipient(amqRoot, safeHandle, welcomeMessageId);
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
deliveryError = error;
|
|
1060
|
+
deliveredMessage = findMessageForRecipient(amqRoot, safeHandle, welcomeMessageId);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (!deliveredMessage) {
|
|
1065
|
+
profileData.welcome = {
|
|
1066
|
+
status: "pending",
|
|
1067
|
+
messageId: welcomeMessageId,
|
|
1068
|
+
lastError: normalizeText(
|
|
1069
|
+
deliveryError?.message,
|
|
1070
|
+
"Welcome delivery was not observed",
|
|
1071
|
+
512
|
|
1072
|
+
),
|
|
1073
|
+
};
|
|
1074
|
+
writeAgentProfile(agentDir, profileData);
|
|
1075
|
+
return {
|
|
1076
|
+
ok: true,
|
|
1077
|
+
agent: profileData,
|
|
1078
|
+
welcome: { ok: false, pending: true, error: profileData.welcome.lastError },
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
profileData.welcome = {
|
|
1083
|
+
status: "sent",
|
|
1084
|
+
messageId: welcomeMessageId,
|
|
1085
|
+
sentAt: deliveredMessage.header?.created || now,
|
|
1086
|
+
templatePath: rendered.source === "template" && rendered.path ? path.relative(amqRoot, rendered.path) : null,
|
|
1087
|
+
templateSha256: rendered.source === "template" ? rendered.sha256 : null,
|
|
1088
|
+
source: rendered.source,
|
|
1089
|
+
};
|
|
1090
|
+
writeAgentProfile(agentDir, profileData);
|
|
1091
|
+
return {
|
|
1092
|
+
ok: true,
|
|
1093
|
+
agent: profileData,
|
|
1094
|
+
welcome: { ok: true, id: welcomeMessageId, sentAt: profileData.welcome.sentAt },
|
|
1095
|
+
};
|
|
911
1096
|
}
|
|
912
1097
|
|
|
913
1098
|
/**
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
const ALLOWED_TEMPLATES = new Set(["welcome", "doorbell"]);
|
|
6
|
+
const MAX_TEMPLATE_BYTES = 64 * 1024;
|
|
7
|
+
const VARIABLE_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
8
|
+
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
9
|
+
|
|
10
|
+
function normalizeScalar(value) {
|
|
11
|
+
if (typeof value === "string") {
|
|
12
|
+
return value
|
|
13
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
14
|
+
.replace(/`/g, "'")
|
|
15
|
+
.replace(/\s+/g, " ")
|
|
16
|
+
.trim();
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
19
|
+
if (typeof value === "boolean") return value;
|
|
20
|
+
throw new Error("Template values must be finite scalars");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function boundedFallback(fallback) {
|
|
24
|
+
const text = typeof fallback === "string" ? fallback : String(fallback ?? "");
|
|
25
|
+
const bytes = Buffer.from(text, "utf8");
|
|
26
|
+
return bytes.length <= MAX_TEMPLATE_BYTES ? text : bytes.subarray(0, MAX_TEMPLATE_BYTES).toString("utf8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveVariable(context, expression) {
|
|
30
|
+
const key = expression.trim();
|
|
31
|
+
if (!VARIABLE_PATTERN.test(key)) throw new Error(`Unsupported template variable: ${key}`);
|
|
32
|
+
const segments = key.split(".");
|
|
33
|
+
if (segments.some((segment) => FORBIDDEN_SEGMENTS.has(segment))) {
|
|
34
|
+
throw new Error(`Unsupported template variable: ${key}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let value = context;
|
|
38
|
+
for (const segment of segments) {
|
|
39
|
+
if (!value || typeof value !== "object" || !Object.prototype.hasOwnProperty.call(value, segment)) {
|
|
40
|
+
throw new Error(`Missing template variable: ${key}`);
|
|
41
|
+
}
|
|
42
|
+
value = value[segment];
|
|
43
|
+
}
|
|
44
|
+
return normalizeScalar(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function renderTemplate(source, context) {
|
|
48
|
+
if (typeof source !== "string" || !source.trim()) throw new Error("Template is empty");
|
|
49
|
+
if (Buffer.byteLength(source, "utf8") > MAX_TEMPLATE_BYTES) throw new Error("Template is too large");
|
|
50
|
+
|
|
51
|
+
if (source.includes("{%") || source.includes("{#")) throw new Error("Unsupported template syntax");
|
|
52
|
+
const withoutExpressions = source.replace(/{{([\s\S]*?)}}/g, "");
|
|
53
|
+
if (withoutExpressions.includes("{") || withoutExpressions.includes("}")) {
|
|
54
|
+
throw new Error("Malformed template expression");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rendered = source.replace(/{{([\s\S]*?)}}/g, (_, expression) => String(resolveVariable(context, expression)));
|
|
58
|
+
if (Buffer.byteLength(rendered, "utf8") > MAX_TEMPLATE_BYTES) throw new Error("Rendered template is too large");
|
|
59
|
+
return rendered;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function loadLocalTemplate(amqRoot, name) {
|
|
63
|
+
if (!amqRoot || !ALLOWED_TEMPLATES.has(name)) return null;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const root = fs.realpathSync(path.resolve(amqRoot));
|
|
67
|
+
const templateDir = path.join(root, "templates");
|
|
68
|
+
const dirStat = fs.lstatSync(templateDir);
|
|
69
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) return null;
|
|
70
|
+
|
|
71
|
+
const realTemplateDir = fs.realpathSync(templateDir);
|
|
72
|
+
if (realTemplateDir !== templateDir) return null;
|
|
73
|
+
|
|
74
|
+
const templatePath = path.join(templateDir, `${name}.md`);
|
|
75
|
+
const realTemplatePath = fs.realpathSync(templatePath);
|
|
76
|
+
if (path.dirname(realTemplatePath) !== realTemplateDir) return null;
|
|
77
|
+
|
|
78
|
+
const noFollow = fs.constants.O_NOFOLLOW || 0;
|
|
79
|
+
const fd = fs.openSync(templatePath, fs.constants.O_RDONLY | noFollow);
|
|
80
|
+
try {
|
|
81
|
+
const stat = fs.fstatSync(fd);
|
|
82
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_TEMPLATE_BYTES) return null;
|
|
83
|
+
const source = fs
|
|
84
|
+
.readFileSync(fd, "utf8")
|
|
85
|
+
.replace(/^\uFEFF/, "")
|
|
86
|
+
.replace(/\r\n/g, "\n");
|
|
87
|
+
if (!source.trim() || source.includes("\0") || Buffer.byteLength(source, "utf8") > MAX_TEMPLATE_BYTES) return null;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
name,
|
|
91
|
+
path: realTemplatePath,
|
|
92
|
+
source,
|
|
93
|
+
sha256: crypto.createHash("sha256").update(source).digest("hex"),
|
|
94
|
+
};
|
|
95
|
+
} finally {
|
|
96
|
+
fs.closeSync(fd);
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function renderLocalTemplate(amqRoot, name, context, fallback) {
|
|
104
|
+
const safeFallback = boundedFallback(fallback);
|
|
105
|
+
const template = loadLocalTemplate(amqRoot, name);
|
|
106
|
+
if (!template) return { text: safeFallback, source: "fallback", path: null, sha256: null };
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
return {
|
|
110
|
+
text: renderTemplate(template.source, context),
|
|
111
|
+
source: "template",
|
|
112
|
+
path: template.path,
|
|
113
|
+
sha256: template.sha256,
|
|
114
|
+
};
|
|
115
|
+
} catch {
|
|
116
|
+
return { text: safeFallback, source: "fallback", path: null, sha256: null };
|
|
117
|
+
}
|
|
118
|
+
}
|