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/LICENSE +21 -0
- package/README.md +263 -0
- package/bin/herdr-amq.mjs +74 -0
- package/herdr-plugin.toml +57 -0
- package/package.json +50 -0
- package/skills/herdr-amq/SKILL.md +69 -0
- package/src/actions.mjs +573 -0
- package/src/blobs.mjs +348 -0
- package/src/board.mjs +760 -0
- package/src/bridge.mjs +373 -0
- package/src/briefs.mjs +201 -0
- package/src/config.mjs +146 -0
- package/src/herdr.mjs +215 -0
- package/src/index.mjs +4 -0
- package/src/markdown.mjs +167 -0
- package/src/panes.mjs +68 -0
- package/src/protocol.mjs +347 -0
- package/src/server.mjs +773 -0
- package/src/store.mjs +1063 -0
- package/src/web/app.js +3066 -0
- package/src/web/index.html +727 -0
- package/src/web/style.css +3842 -0
- package/src/worktrees.mjs +217 -0
package/src/server.mjs
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { findAmqRoot, getAgentHandles } from "./config.mjs";
|
|
6
|
+
import {
|
|
7
|
+
loadAllMessages,
|
|
8
|
+
loadThreads,
|
|
9
|
+
loadAgentDirectory,
|
|
10
|
+
sendAmqMessage,
|
|
11
|
+
replyAmqMessage,
|
|
12
|
+
resolveAttachmentPath,
|
|
13
|
+
getStorageUsage,
|
|
14
|
+
isPathSafe,
|
|
15
|
+
registerAgent,
|
|
16
|
+
invalidateMessageCache,
|
|
17
|
+
} from "./store.mjs";
|
|
18
|
+
import {
|
|
19
|
+
listWorktrees,
|
|
20
|
+
createWorktree,
|
|
21
|
+
removeWorktree,
|
|
22
|
+
ensureAgentWorktree,
|
|
23
|
+
ensureAllWorktrees,
|
|
24
|
+
} from "./worktrees.mjs";
|
|
25
|
+
import {
|
|
26
|
+
scanAgentBriefs,
|
|
27
|
+
getAgentBrief,
|
|
28
|
+
saveAgentBrief,
|
|
29
|
+
} from "./briefs.mjs";
|
|
30
|
+
import {
|
|
31
|
+
isDaemonRunning,
|
|
32
|
+
startDaemonBackground,
|
|
33
|
+
stopDaemon,
|
|
34
|
+
listInbox,
|
|
35
|
+
} from "./bridge.mjs";
|
|
36
|
+
import {
|
|
37
|
+
getHerdrAgents,
|
|
38
|
+
getHerdrStatusMap,
|
|
39
|
+
subscribeHerdrEvents,
|
|
40
|
+
isHerdrAvailable,
|
|
41
|
+
getSocketPath,
|
|
42
|
+
} from "./herdr.mjs";
|
|
43
|
+
import {
|
|
44
|
+
findStatusFile,
|
|
45
|
+
loadBoard,
|
|
46
|
+
addBoardTask,
|
|
47
|
+
updateBoardTask,
|
|
48
|
+
deleteBoardTask,
|
|
49
|
+
} from "./board.mjs";
|
|
50
|
+
import {
|
|
51
|
+
getBlob,
|
|
52
|
+
readGitRef,
|
|
53
|
+
storeBlob,
|
|
54
|
+
} from "./blobs.mjs";
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
59
|
+
const WEB_ROOT = path.join(__dirname, "web");
|
|
60
|
+
|
|
61
|
+
export function startWebServer({
|
|
62
|
+
port = 8505,
|
|
63
|
+
host = process.env.AGMAIL_HOST || "127.0.0.1",
|
|
64
|
+
amqRoot = findAmqRoot(),
|
|
65
|
+
} = {}) {
|
|
66
|
+
if (!amqRoot) {
|
|
67
|
+
console.error("❌ Cannot start web server: No .agent-mail directory found.");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Active SSE clients
|
|
72
|
+
const sseClients = new Set();
|
|
73
|
+
|
|
74
|
+
// ─── Herdr live agent status cache ─────────────────────────────────────────
|
|
75
|
+
// Map<handle, { herdrStatus, herdrPaneId, herdrWorkspaceId, ... }>
|
|
76
|
+
let herdrStatusCache = new Map();
|
|
77
|
+
let herdrSubscription = null;
|
|
78
|
+
|
|
79
|
+
function broadcastSSE(payload) {
|
|
80
|
+
const msg = `data: ${JSON.stringify(payload)}\n\n`;
|
|
81
|
+
for (const client of sseClients) {
|
|
82
|
+
try { client.write(msg); } catch {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function refreshHerdrCache() {
|
|
87
|
+
try {
|
|
88
|
+
herdrStatusCache = await getHerdrStatusMap();
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let isClosing = false;
|
|
93
|
+
let herdrReconnectTimeout = null;
|
|
94
|
+
|
|
95
|
+
function startHerdrSubscription() {
|
|
96
|
+
if (isClosing) return;
|
|
97
|
+
if (herdrSubscription) {
|
|
98
|
+
try { herdrSubscription.close(); } catch {}
|
|
99
|
+
}
|
|
100
|
+
herdrSubscription = subscribeHerdrEvents({
|
|
101
|
+
onConnect: () => {
|
|
102
|
+
// Refresh snapshot on connect so our cache is up to date
|
|
103
|
+
refreshHerdrCache();
|
|
104
|
+
},
|
|
105
|
+
onEvent: (event) => {
|
|
106
|
+
const t = event.type;
|
|
107
|
+
// Agent state changed — update cache and notify SSE clients
|
|
108
|
+
if (t === "agent.state_changed" || t === "agent.updated") {
|
|
109
|
+
const handle = event.name;
|
|
110
|
+
if (handle) {
|
|
111
|
+
const existing = herdrStatusCache.get(handle) || {};
|
|
112
|
+
herdrStatusCache.set(handle, {
|
|
113
|
+
...existing,
|
|
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();
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
onDisconnect: () => {
|
|
137
|
+
// Reconnect after 5s if Herdr socket drops
|
|
138
|
+
if (!isClosing) {
|
|
139
|
+
herdrReconnectTimeout = setTimeout(startHerdrSubscription, 5000);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Start Herdr integration (non-fatal if Herdr not running)
|
|
146
|
+
refreshHerdrCache().then(() => startHerdrSubscription()).catch(() => {});
|
|
147
|
+
|
|
148
|
+
let watchDebounce = null;
|
|
149
|
+
let watcher = null;
|
|
150
|
+
try {
|
|
151
|
+
watcher = fs.watch(amqRoot, { recursive: true }, (eventType, filename) => {
|
|
152
|
+
if (!filename || filename.includes(".git") || filename.includes(".tmp")) return;
|
|
153
|
+
|
|
154
|
+
invalidateMessageCache();
|
|
155
|
+
|
|
156
|
+
clearTimeout(watchDebounce);
|
|
157
|
+
watchDebounce = setTimeout(() => {
|
|
158
|
+
broadcastSSE({ type: "mail_update", at: new Date().toISOString() });
|
|
159
|
+
}, 200);
|
|
160
|
+
});
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.warn(`[web] Warning: could not setup fs.watch on ${amqRoot}: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Watch .opencode/bus/STATUS.md for real-time board updates
|
|
166
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
167
|
+
const statusFile = findStatusFile(repoRoot);
|
|
168
|
+
let statusWatcher = null;
|
|
169
|
+
let statusDebounce = null;
|
|
170
|
+
if (statusFile && fs.existsSync(statusFile)) {
|
|
171
|
+
try {
|
|
172
|
+
statusWatcher = fs.watch(statusFile, () => {
|
|
173
|
+
clearTimeout(statusDebounce);
|
|
174
|
+
statusDebounce = setTimeout(() => {
|
|
175
|
+
broadcastSSE({ type: "board_update", at: new Date().toISOString() });
|
|
176
|
+
}, 150);
|
|
177
|
+
});
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
const server = http.createServer(async (req, res) => {
|
|
184
|
+
// ─── Compliance: Strict Security Headers ───────────────────────────────
|
|
185
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
186
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
187
|
+
res.setHeader(
|
|
188
|
+
"Content-Security-Policy",
|
|
189
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none';"
|
|
190
|
+
);
|
|
191
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
192
|
+
|
|
193
|
+
// ─── Compliance: Host Header & DNS Rebinding Protection ───────────────
|
|
194
|
+
const rawHost = req.headers.host || "";
|
|
195
|
+
const hostHeader = rawHost.split(":")[0].toLowerCase();
|
|
196
|
+
const isLocalHost =
|
|
197
|
+
hostHeader === "localhost" ||
|
|
198
|
+
hostHeader === "127.0.0.1" ||
|
|
199
|
+
hostHeader === "[::1]" ||
|
|
200
|
+
hostHeader === "::1" ||
|
|
201
|
+
!rawHost; // In-memory or direct tests without host header
|
|
202
|
+
|
|
203
|
+
if (!isLocalHost) {
|
|
204
|
+
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
|
|
205
|
+
res.end(
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
error: "Forbidden: Invalid Host header (DNS rebinding protection). AGmail is strictly local-only.",
|
|
208
|
+
rejectedHost: rawHost,
|
|
209
|
+
})
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Compliance: Null-byte injection check ──────────────────────────────
|
|
215
|
+
if (req.url && (req.url.includes("\0") || req.url.includes("%00"))) {
|
|
216
|
+
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
|
|
217
|
+
res.end(JSON.stringify({ error: "Forbidden: Null-byte injection detected" }));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
222
|
+
const pathname = url.pathname;
|
|
223
|
+
|
|
224
|
+
// ─── API Routes ──────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
if (pathname === "/api/status" && req.method === "GET") {
|
|
227
|
+
const pid = isDaemonRunning();
|
|
228
|
+
const handles = getAgentHandles(amqRoot);
|
|
229
|
+
let totalUnread = 0;
|
|
230
|
+
for (const h of handles) {
|
|
231
|
+
totalUnread += listInbox(amqRoot, h).length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
235
|
+
res.end(
|
|
236
|
+
JSON.stringify({
|
|
237
|
+
ok: true,
|
|
238
|
+
amqRoot,
|
|
239
|
+
daemonRunning: Boolean(pid),
|
|
240
|
+
pid: pid || null,
|
|
241
|
+
totalUnread,
|
|
242
|
+
agentCount: handles.length,
|
|
243
|
+
storage: getStorageUsage(amqRoot),
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (pathname === "/api/models" && req.method === "GET") {
|
|
250
|
+
// Suggestion models list for flexible combo input (not hardcoded restricted)
|
|
251
|
+
const models = [
|
|
252
|
+
{ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet (Thinking & Code)" },
|
|
253
|
+
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet" },
|
|
254
|
+
{ id: "claude-3-5-haiku", name: "Claude 3.5 Haiku" },
|
|
255
|
+
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
|
256
|
+
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
|
257
|
+
{ id: "gpt-4o", name: "GPT-4o" },
|
|
258
|
+
{ id: "o3-mini", name: "o3-mini" },
|
|
259
|
+
{ id: "deepseek-r1", name: "DeepSeek R1" },
|
|
260
|
+
{ id: "deepseek-chat", name: "DeepSeek V3" },
|
|
261
|
+
{ id: "ollama/qwen2.5-coder", name: "Qwen 2.5 Coder (Local Ollama)" },
|
|
262
|
+
{ id: "ollama/llama3.3", name: "Llama 3.3 (Local Ollama)" },
|
|
263
|
+
];
|
|
264
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
265
|
+
res.end(JSON.stringify(models));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (pathname === "/api/agents" && req.method === "GET") {
|
|
270
|
+
const agents = loadAgentDirectory(amqRoot);
|
|
271
|
+
// Merge live Herdr status into each agent record
|
|
272
|
+
const enriched = agents.map((a) => {
|
|
273
|
+
const h = herdrStatusCache.get(a.handle);
|
|
274
|
+
if (!h) return a;
|
|
275
|
+
return {
|
|
276
|
+
...a,
|
|
277
|
+
herdrStatus: h.herdrStatus,
|
|
278
|
+
herdrPaneId: h.herdrPaneId,
|
|
279
|
+
herdrWorkspaceId: h.herdrWorkspaceId,
|
|
280
|
+
herdrTabId: h.herdrTabId,
|
|
281
|
+
herdrTitle: h.herdrTitle,
|
|
282
|
+
interactiveReady: h.interactiveReady,
|
|
283
|
+
agentType: h.agentType,
|
|
284
|
+
// Promote herdrStatus as the primary status when available
|
|
285
|
+
status: h.herdrStatus !== "unknown" ? h.herdrStatus : (a.status || "offline"),
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
289
|
+
res.end(JSON.stringify(enriched));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (pathname === "/api/herdr-agents" && req.method === "GET") {
|
|
294
|
+
// Raw Herdr agent snapshot — all panes, not just named ones
|
|
295
|
+
const agents = await getHerdrAgents();
|
|
296
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
297
|
+
res.end(JSON.stringify(agents));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (pathname === "/api/agents" && req.method === "POST") {
|
|
302
|
+
const body = await parseJsonBody(req);
|
|
303
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
304
|
+
// Workspaces are the default under the hood: automatically isolate agent in .worktrees/<handle>
|
|
305
|
+
const worktreeResult = ensureAgentWorktree(repoRoot, body.handle, body.branch);
|
|
306
|
+
const result = registerAgent(amqRoot, {
|
|
307
|
+
...body,
|
|
308
|
+
worktree: worktreeResult?.ok ? worktreeResult.path : body.worktree,
|
|
309
|
+
});
|
|
310
|
+
// Refresh Herdr cache after registration
|
|
311
|
+
refreshHerdrCache().catch(() => {});
|
|
312
|
+
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
313
|
+
res.end(JSON.stringify({ ...result, worktreeResult }));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if (pathname === "/api/agent-briefs" && req.method === "GET") {
|
|
319
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
320
|
+
const handle = url.searchParams.get("handle");
|
|
321
|
+
if (handle) {
|
|
322
|
+
const brief = getAgentBrief(repoRoot, handle);
|
|
323
|
+
if (brief) {
|
|
324
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
325
|
+
res.end(JSON.stringify({ ok: true, brief }));
|
|
326
|
+
} else {
|
|
327
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
328
|
+
res.end(JSON.stringify({ ok: false, error: `Brief not found for ${handle}` }));
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const briefsMap = scanAgentBriefs(repoRoot);
|
|
333
|
+
const list = Array.from(briefsMap.values());
|
|
334
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
335
|
+
res.end(JSON.stringify(list));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (pathname.startsWith("/api/agent-briefs/") && req.method === "GET") {
|
|
340
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
341
|
+
const handle = decodeURIComponent(pathname.slice("/api/agent-briefs/".length));
|
|
342
|
+
const brief = getAgentBrief(repoRoot, handle);
|
|
343
|
+
if (brief) {
|
|
344
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
345
|
+
res.end(JSON.stringify({ ok: true, brief }));
|
|
346
|
+
} else {
|
|
347
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
348
|
+
res.end(JSON.stringify({ ok: false, error: `Brief not found for ${handle}` }));
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (pathname === "/api/agent-briefs" && req.method === "POST") {
|
|
354
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
355
|
+
const body = await parseJsonBody(req);
|
|
356
|
+
const result = saveAgentBrief(repoRoot, body.handle, body);
|
|
357
|
+
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
358
|
+
res.end(JSON.stringify(result));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (pathname === "/api/worktrees" && req.method === "GET") {
|
|
363
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
364
|
+
const worktrees = listWorktrees(repoRoot);
|
|
365
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
366
|
+
res.end(JSON.stringify(worktrees));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (pathname === "/api/worktrees" && req.method === "POST") {
|
|
371
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
372
|
+
const body = await parseJsonBody(req);
|
|
373
|
+
const result = createWorktree(repoRoot, body);
|
|
374
|
+
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
375
|
+
res.end(JSON.stringify(result));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (pathname === "/api/worktrees/ensure" && req.method === "POST") {
|
|
380
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
381
|
+
const body = await parseJsonBody(req);
|
|
382
|
+
const result = ensureAgentWorktree(repoRoot, body.handle, body.branch);
|
|
383
|
+
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
384
|
+
res.end(JSON.stringify(result));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (pathname === "/api/worktrees/ensure-all" && req.method === "POST") {
|
|
389
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
390
|
+
const handles = getAgentHandles(amqRoot);
|
|
391
|
+
const results = ensureAllWorktrees(repoRoot, handles);
|
|
392
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
393
|
+
res.end(JSON.stringify({ ok: true, results }));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (pathname === "/api/worktrees" && req.method === "DELETE") {
|
|
398
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
399
|
+
const body = await parseJsonBody(req);
|
|
400
|
+
const targetPath = body.targetPath || url.searchParams.get("path");
|
|
401
|
+
const force = body.force ?? (url.searchParams.get("force") === "true");
|
|
402
|
+
const result = removeWorktree(repoRoot, { targetPath, force });
|
|
403
|
+
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
404
|
+
res.end(JSON.stringify(result));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ─── Kanban Board Routes ─────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
if (pathname === "/api/board" && req.method === "GET") {
|
|
411
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
412
|
+
const board = loadBoard(repoRoot, amqRoot);
|
|
413
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
414
|
+
res.end(JSON.stringify({ ok: true, ...board }));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (pathname === "/api/board/tasks" && req.method === "POST") {
|
|
419
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
420
|
+
const body = await parseJsonBody(req);
|
|
421
|
+
const result = addBoardTask(repoRoot, amqRoot, body);
|
|
422
|
+
if (result.ok) {
|
|
423
|
+
broadcastSSE({ type: "board_update", at: new Date().toISOString() });
|
|
424
|
+
}
|
|
425
|
+
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
426
|
+
res.end(JSON.stringify(result));
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (pathname.startsWith("/api/board/tasks/") && req.method === "PATCH") {
|
|
431
|
+
const taskId = decodeURIComponent(pathname.slice("/api/board/tasks/".length));
|
|
432
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
433
|
+
const body = await parseJsonBody(req);
|
|
434
|
+
const result = updateBoardTask(repoRoot, amqRoot, taskId, body);
|
|
435
|
+
if (result.ok) {
|
|
436
|
+
broadcastSSE({ type: "board_update", at: new Date().toISOString() });
|
|
437
|
+
}
|
|
438
|
+
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
439
|
+
res.end(JSON.stringify(result));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (pathname.startsWith("/api/board/tasks/") && req.method === "DELETE") {
|
|
444
|
+
const taskId = decodeURIComponent(pathname.slice("/api/board/tasks/".length));
|
|
445
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
446
|
+
const result = deleteBoardTask(repoRoot, amqRoot, taskId);
|
|
447
|
+
if (result.ok) {
|
|
448
|
+
broadcastSSE({ type: "board_update", at: new Date().toISOString() });
|
|
449
|
+
}
|
|
450
|
+
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
451
|
+
res.end(JSON.stringify(result));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
if (pathname === "/api/messages" && req.method === "GET") {
|
|
457
|
+
const account = url.searchParams.get("account") || "all";
|
|
458
|
+
const folder = url.searchParams.get("folder") || "inbox";
|
|
459
|
+
const query = url.searchParams.get("query") || "";
|
|
460
|
+
const persona = url.searchParams.get("persona") || "";
|
|
461
|
+
const page = url.searchParams.get("page") ? parseInt(url.searchParams.get("page"), 10) : undefined;
|
|
462
|
+
const pageSize = url.searchParams.get("pageSize") ? parseInt(url.searchParams.get("pageSize"), 10) : 50;
|
|
463
|
+
const paginate = url.searchParams.get("paginate") === "true" || page !== undefined;
|
|
464
|
+
|
|
465
|
+
const msgs = loadAllMessages(amqRoot, { account, folder, query, persona, page, pageSize, paginate });
|
|
466
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
467
|
+
res.end(JSON.stringify(msgs));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (pathname === "/api/threads" && req.method === "GET") {
|
|
472
|
+
const account = url.searchParams.get("account") || "all";
|
|
473
|
+
const folder = url.searchParams.get("folder") || "inbox";
|
|
474
|
+
const query = url.searchParams.get("query") || "";
|
|
475
|
+
const persona = url.searchParams.get("persona") || "";
|
|
476
|
+
const page = url.searchParams.get("page") ? parseInt(url.searchParams.get("page"), 10) : undefined;
|
|
477
|
+
const pageSize = url.searchParams.get("pageSize") ? parseInt(url.searchParams.get("pageSize"), 10) : 50;
|
|
478
|
+
const paginate = url.searchParams.get("paginate") === "true" || page !== undefined;
|
|
479
|
+
|
|
480
|
+
const threads = loadThreads(amqRoot, { account, folder, query, persona, page, pageSize, paginate });
|
|
481
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
482
|
+
res.end(JSON.stringify(threads));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (pathname === "/api/send" && req.method === "POST") {
|
|
487
|
+
const body = await parseJsonBody(req);
|
|
488
|
+
const result = sendAmqMessage(amqRoot, body);
|
|
489
|
+
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
490
|
+
res.end(JSON.stringify(result));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (pathname === "/api/reply" && req.method === "POST") {
|
|
495
|
+
const body = await parseJsonBody(req);
|
|
496
|
+
const result = replyAmqMessage(amqRoot, body);
|
|
497
|
+
res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
498
|
+
res.end(JSON.stringify(result));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (pathname === "/api/bridge/toggle" && req.method === "POST") {
|
|
503
|
+
const pid = isDaemonRunning();
|
|
504
|
+
let result;
|
|
505
|
+
if (pid) {
|
|
506
|
+
result = stopDaemon();
|
|
507
|
+
} else {
|
|
508
|
+
result = startDaemonBackground();
|
|
509
|
+
}
|
|
510
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
511
|
+
res.end(JSON.stringify(result));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─── Option A: Content-Addressed Blobstore Endpoint ───────────────────
|
|
516
|
+
if ((pathname.startsWith("/api/blob/") || pathname.startsWith("/api/blobs/")) && (req.method === "GET" || req.method === "HEAD")) {
|
|
517
|
+
const parts = pathname.split("/");
|
|
518
|
+
const hashPart = parts[3] || parts[2] || "";
|
|
519
|
+
const cleanHash = hashPart.split(".")[0];
|
|
520
|
+
|
|
521
|
+
const blob = getBlob(cleanHash, amqRoot);
|
|
522
|
+
if (!blob || !fs.existsSync(blob.filePath)) {
|
|
523
|
+
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
|
524
|
+
res.end(JSON.stringify({ error: "Blob not found in store", sha256: cleanHash }));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
res.writeHead(200, {
|
|
529
|
+
"Content-Type": blob.mime,
|
|
530
|
+
"Content-Length": blob.sizeBytes,
|
|
531
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
532
|
+
});
|
|
533
|
+
if (req.method === "HEAD") {
|
|
534
|
+
res.end();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const stream = fs.createReadStream(blob.filePath);
|
|
538
|
+
stream.pipe(res);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (pathname === "/api/blobs" && req.method === "POST") {
|
|
543
|
+
let bodyBuffers = [];
|
|
544
|
+
req.on("data", (chunk) => bodyBuffers.push(chunk));
|
|
545
|
+
req.on("end", () => {
|
|
546
|
+
try {
|
|
547
|
+
const totalBuffer = Buffer.concat(bodyBuffers);
|
|
548
|
+
if (!totalBuffer.length) {
|
|
549
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
550
|
+
res.end(JSON.stringify({ error: "Empty upload content" }));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const filename = url.searchParams.get("name") || "artifact";
|
|
554
|
+
const blobDesc = storeBlob(totalBuffer, amqRoot, filename);
|
|
555
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
556
|
+
res.end(JSON.stringify({ ok: true, blob: blobDesc }));
|
|
557
|
+
} catch (err) {
|
|
558
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
559
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ─── Option B: Git Commit & Object Pinning Endpoint ────────────────────
|
|
566
|
+
if (pathname === "/api/git-file" && (req.method === "GET" || req.method === "HEAD")) {
|
|
567
|
+
const commit = url.searchParams.get("commit");
|
|
568
|
+
const gitPath = url.searchParams.get("path");
|
|
569
|
+
if (!commit || !gitPath) {
|
|
570
|
+
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
571
|
+
res.end(JSON.stringify({ error: "Missing required commit or path parameter" }));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
576
|
+
const gitRef = readGitRef(repoRoot, commit, gitPath);
|
|
577
|
+
if (!gitRef) {
|
|
578
|
+
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
|
579
|
+
res.end(JSON.stringify({ error: "Git object not found at commit", commit, path: gitPath }));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
res.writeHead(200, {
|
|
584
|
+
"Content-Type": gitRef.mime,
|
|
585
|
+
"Content-Length": gitRef.sizeBytes,
|
|
586
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
587
|
+
});
|
|
588
|
+
if (req.method === "HEAD") {
|
|
589
|
+
res.end();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
res.end(gitRef.buffer);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (pathname === "/api/file" && (req.method === "GET" || req.method === "HEAD")) {
|
|
597
|
+
const targetPath = url.searchParams.get("path");
|
|
598
|
+
if (!targetPath) {
|
|
599
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
600
|
+
res.end("Missing path parameter");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
605
|
+
|
|
606
|
+
// Security check: reject forbidden path tokens
|
|
607
|
+
const lowerReq = targetPath.toLowerCase();
|
|
608
|
+
const forbiddenTokens = [
|
|
609
|
+
".ssh",
|
|
610
|
+
".env",
|
|
611
|
+
".git",
|
|
612
|
+
"/etc",
|
|
613
|
+
"/proc",
|
|
614
|
+
"/sys",
|
|
615
|
+
"/root",
|
|
616
|
+
"id_rsa",
|
|
617
|
+
"id_ed25519",
|
|
618
|
+
"credentials",
|
|
619
|
+
".pem",
|
|
620
|
+
".key",
|
|
621
|
+
".bash_history",
|
|
622
|
+
];
|
|
623
|
+
if (forbiddenTokens.some((token) => lowerReq.includes(token))) {
|
|
624
|
+
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
|
|
625
|
+
res.end(JSON.stringify({ error: "Access denied: path contains forbidden patterns", requested: targetPath }));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// If absolute path was requested, verify it is strictly within allowed roots
|
|
630
|
+
if (path.isAbsolute(targetPath) && !isPathSafe(targetPath, repoRoot, amqRoot)) {
|
|
631
|
+
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
|
|
632
|
+
res.end(JSON.stringify({ error: "Access denied: absolute path outside allowed roots", requested: targetPath }));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const resolved = resolveAttachmentPath(targetPath, amqRoot);
|
|
637
|
+
if (!resolved || !isPathSafe(resolved, repoRoot, amqRoot) || !fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
638
|
+
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
|
639
|
+
res.end(JSON.stringify({ error: "File not found or access denied", requested: targetPath }));
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
644
|
+
const mimeTypes = {
|
|
645
|
+
".png": "image/png",
|
|
646
|
+
".jpg": "image/jpeg",
|
|
647
|
+
".jpeg": "image/jpeg",
|
|
648
|
+
".gif": "image/gif",
|
|
649
|
+
".webp": "image/webp",
|
|
650
|
+
".svg": "image/svg+xml",
|
|
651
|
+
".bmp": "image/bmp",
|
|
652
|
+
".log": "text/plain; charset=utf-8",
|
|
653
|
+
".txt": "text/plain; charset=utf-8",
|
|
654
|
+
".csv": "text/plain; charset=utf-8",
|
|
655
|
+
".json": "application/json; charset=utf-8",
|
|
656
|
+
".gd": "text/plain; charset=utf-8",
|
|
657
|
+
".tscn": "text/plain; charset=utf-8",
|
|
658
|
+
".tres": "text/plain; charset=utf-8",
|
|
659
|
+
".md": "text/markdown; charset=utf-8",
|
|
660
|
+
".sh": "text/plain; charset=utf-8",
|
|
661
|
+
".diff": "text/plain; charset=utf-8",
|
|
662
|
+
".patch": "text/plain; charset=utf-8",
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const contentType = mimeTypes[ext] || "application/octet-stream";
|
|
666
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
667
|
+
if (req.method === "HEAD") {
|
|
668
|
+
res.end();
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
fs.createReadStream(resolved).pipe(res);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (pathname === "/api/events" && req.method === "GET") {
|
|
676
|
+
res.writeHead(200, {
|
|
677
|
+
"Content-Type": "text/event-stream",
|
|
678
|
+
"Cache-Control": "no-cache",
|
|
679
|
+
Connection: "keep-alive",
|
|
680
|
+
});
|
|
681
|
+
res.write(": ok\n\n");
|
|
682
|
+
|
|
683
|
+
sseClients.add(res);
|
|
684
|
+
|
|
685
|
+
req.on("close", () => {
|
|
686
|
+
sseClients.delete(res);
|
|
687
|
+
});
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ─── Static Files ────────────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
let filePath;
|
|
694
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
695
|
+
filePath = path.join(WEB_ROOT, "index.html");
|
|
696
|
+
} else if (pathname === "/style.css") {
|
|
697
|
+
filePath = path.join(WEB_ROOT, "style.css");
|
|
698
|
+
} else if (pathname === "/app.js") {
|
|
699
|
+
filePath = path.join(WEB_ROOT, "app.js");
|
|
700
|
+
} else {
|
|
701
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
702
|
+
res.end("Not Found");
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (fs.existsSync(filePath)) {
|
|
707
|
+
const ext = path.extname(filePath);
|
|
708
|
+
const mime =
|
|
709
|
+
ext === ".html"
|
|
710
|
+
? "text/html; charset=utf-8"
|
|
711
|
+
: ext === ".css"
|
|
712
|
+
? "text/css; charset=utf-8"
|
|
713
|
+
: "application/javascript; charset=utf-8";
|
|
714
|
+
|
|
715
|
+
res.writeHead(200, { "Content-Type": mime });
|
|
716
|
+
fs.createReadStream(filePath).pipe(res);
|
|
717
|
+
} else {
|
|
718
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
719
|
+
res.end("File not found");
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
server.listen(port, host, () => {
|
|
724
|
+
console.log(`\x1b[32m● AGmail Webmail Server running at:\x1b[0m \x1b[1mhttp://${host}:${port}\x1b[0m (local only)`);
|
|
725
|
+
console.log(` Queue Root: \x1b[36m${amqRoot}\x1b[0m`);
|
|
726
|
+
if (host !== "127.0.0.1" && host !== "localhost") {
|
|
727
|
+
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
|
+
}
|
|
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 {}
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
server.on("close", () => {
|
|
738
|
+
isClosing = true;
|
|
739
|
+
if (herdrReconnectTimeout) clearTimeout(herdrReconnectTimeout);
|
|
740
|
+
if (watchDebounce) clearTimeout(watchDebounce);
|
|
741
|
+
if (herdrSubscription) {
|
|
742
|
+
try { herdrSubscription.close(); } catch {}
|
|
743
|
+
}
|
|
744
|
+
if (watcher) {
|
|
745
|
+
try { watcher.close(); } catch {}
|
|
746
|
+
}
|
|
747
|
+
if (statusWatcher) {
|
|
748
|
+
try { statusWatcher.close(); } catch {}
|
|
749
|
+
}
|
|
750
|
+
for (const client of sseClients) {
|
|
751
|
+
try { client.end(); } catch {}
|
|
752
|
+
}
|
|
753
|
+
sseClients.clear();
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
return server;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function parseJsonBody(req) {
|
|
760
|
+
return new Promise((resolve) => {
|
|
761
|
+
let acc = "";
|
|
762
|
+
req.on("data", (chunk) => {
|
|
763
|
+
acc += chunk;
|
|
764
|
+
});
|
|
765
|
+
req.on("end", () => {
|
|
766
|
+
try {
|
|
767
|
+
resolve(JSON.parse(acc));
|
|
768
|
+
} catch {
|
|
769
|
+
resolve({});
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
}
|