@politeia/openclaw-bridge 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.
@@ -0,0 +1,760 @@
1
+ #!/usr/bin/env node
2
+
3
+ import http from "node:http";
4
+ import { execFile } from "node:child_process";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { existsSync } from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { promisify } from "node:util";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ const HOST = process.env.HOST ?? "127.0.0.1";
16
+ const PORT = Number(process.env.PORT ?? "8790");
17
+ const OPENCLAW_BIN = process.env.OPENCLAW_BIN ?? "openclaw";
18
+ const CACHE_TTL_MS = Number(process.env.AGENTCOMM_BOARD_CACHE_TTL_MS ?? "5000");
19
+ const ASSET_DIR = process.env.AGENTCOMM_BOARD_ASSET_DIR ?? path.resolve(__dirname, "../dist/user-board");
20
+
21
+ const cache = new Map();
22
+
23
+ async function cached(key, loader, ttlMs = CACHE_TTL_MS) {
24
+ const hit = cache.get(key);
25
+ if (hit && Date.now() - hit.loadedAt <= ttlMs) {
26
+ return hit.value;
27
+ }
28
+ const value = await loader();
29
+ cache.set(key, { loadedAt: Date.now(), value });
30
+ return value;
31
+ }
32
+
33
+ async function callGateway(method, params = null, options = {}) {
34
+ const args = ["gateway", "call", method, "--json", "--expect-final"];
35
+ if (params) {
36
+ args.push("--params", JSON.stringify(params));
37
+ }
38
+ const { stdout } = await execFileAsync(OPENCLAW_BIN, args, {
39
+ timeout: options.timeoutMs ?? 12_000,
40
+ });
41
+ return stdout.trim().length > 0 ? JSON.parse(stdout) : {};
42
+ }
43
+
44
+ async function getWorkspaceStatus() {
45
+ if (process.env.AGENTCOMM_USER_WORKSPACE_DIR) {
46
+ return {
47
+ ok: true,
48
+ source: "AGENTCOMM_USER_WORKSPACE_DIR",
49
+ rootDir: process.env.AGENTCOMM_USER_WORKSPACE_DIR,
50
+ workspaceRootDir: process.env.AGENTCOMM_USER_WORKSPACE_DIR,
51
+ offline: false,
52
+ };
53
+ }
54
+
55
+ try {
56
+ return {
57
+ ...(await cached("workspaceStatus", () => callGateway("communityBridge.workspaceStatus", null, { timeoutMs: 8000 }))),
58
+ source: "openclaw-gateway",
59
+ offline: false,
60
+ };
61
+ } catch (error) {
62
+ const fallback = await resolveFallbackWorkspace();
63
+ return {
64
+ ok: Boolean(fallback.rootDir),
65
+ source: "local-fallback",
66
+ offline: true,
67
+ error: error instanceof Error ? error.message : String(error),
68
+ rootDir: fallback.rootDir,
69
+ workspaceRootDir: fallback.rootDir,
70
+ communityAgentId: fallback.communityAgentId,
71
+ };
72
+ }
73
+ }
74
+
75
+ async function resolveFallbackWorkspace() {
76
+ const config = await readJsonFile(path.join(os.homedir(), ".openclaw", "openclaw.json"), {});
77
+ const bridgeConfig = config?.plugins?.entries?.["openclaw-bridge"]?.config ?? {};
78
+ const communityAgentId =
79
+ typeof bridgeConfig.communityAgentId === "string" && bridgeConfig.communityAgentId.length > 0
80
+ ? bridgeConfig.communityAgentId
81
+ : null;
82
+ const agentId = typeof bridgeConfig.agentId === "string" && bridgeConfig.agentId.length > 0 ? bridgeConfig.agentId : "main";
83
+ const candidates = [
84
+ process.env.AGENTCOMM_USER_WORKSPACE_DIR,
85
+ path.join(os.homedir(), ".openclaw", "agents", agentId, "agent", "community"),
86
+ communityAgentId ? path.join(os.homedir(), ".openclaw", "workspace", ".community", communityAgentId) : null,
87
+ ].filter(Boolean);
88
+
89
+ return {
90
+ communityAgentId,
91
+ rootDir: candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] ?? null,
92
+ };
93
+ }
94
+
95
+ function workspaceFiles(rootDir) {
96
+ return {
97
+ state: path.join(rootDir, "state.json"),
98
+ persona: path.join(rootDir, "persona", "profile.json"),
99
+ delegationPolicy: path.join(rootDir, "persona", "delegation-policy.json"),
100
+ auditLog: path.join(rootDir, "persona", "audit-log.jsonl"),
101
+ inbox: path.join(rootDir, "inbox", "received-direct-messages.jsonl"),
102
+ sentDms: path.join(rootDir, "outbox", "sent-direct-messages.jsonl"),
103
+ roomsIndex: path.join(rootDir, "rooms", "index.json"),
104
+ roomsDir: path.join(rootDir, "rooms"),
105
+ meetingSummaries: path.join(rootDir, "rooms", "meeting-summaries.jsonl"),
106
+ posts: path.join(rootDir, "feed-cache", "posts.jsonl"),
107
+ skills: path.join(rootDir, "skills", "index.json"),
108
+ pendingActions: path.join(rootDir, "confirmations", "pending-actions.jsonl"),
109
+ decisions: path.join(rootDir, "confirmations", "action-decisions.jsonl"),
110
+ results: path.join(rootDir, "confirmations", "action-results.jsonl"),
111
+ activities: path.join(rootDir, "persona", "activity-log.jsonl"),
112
+ handoff: path.join(rootDir, "subagent-workspace", ".agentcomm", "inbox", "handoff.jsonl"),
113
+ boardState: path.join(rootDir, "board-state.json"),
114
+ };
115
+ }
116
+
117
+ async function readJsonFile(filePath, fallback = null) {
118
+ try {
119
+ const raw = await readFile(filePath, "utf8");
120
+ return JSON.parse(raw);
121
+ } catch {
122
+ return fallback;
123
+ }
124
+ }
125
+
126
+ async function writeJsonFile(filePath, value) {
127
+ await mkdir(path.dirname(filePath), { recursive: true });
128
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
129
+ }
130
+
131
+ async function readJsonLines(filePath) {
132
+ try {
133
+ const raw = await readFile(filePath, "utf8");
134
+ return raw
135
+ .split("\n")
136
+ .map((line) => line.trim())
137
+ .filter(Boolean)
138
+ .map((line) => JSON.parse(line));
139
+ } catch {
140
+ return [];
141
+ }
142
+ }
143
+
144
+ async function getAgentDirectory() {
145
+ try {
146
+ const response = await cached("agentDirectory", () => callGateway("communityBridge.listAgents", null, { timeoutMs: 8000 }));
147
+ const items = Array.isArray(response?.items) ? response.items : [];
148
+ return new Map(items.map((agent) => [agent.agent_id, agent]));
149
+ } catch {
150
+ return new Map();
151
+ }
152
+ }
153
+
154
+ async function loadBoardData() {
155
+ const status = await getWorkspaceStatus();
156
+ const rootDir = status.rootDir ?? status.workspaceRootDir;
157
+ if (!rootDir) {
158
+ throw new Error("Community workspace is unavailable.");
159
+ }
160
+
161
+ const files = workspaceFiles(rootDir);
162
+ const [
163
+ state,
164
+ persona,
165
+ delegationPolicy,
166
+ auditLog,
167
+ inbox,
168
+ sentDms,
169
+ roomsRaw,
170
+ meetingSummaries,
171
+ posts,
172
+ skillsRaw,
173
+ pendingActions,
174
+ decisions,
175
+ results,
176
+ activities,
177
+ handoffs,
178
+ boardState,
179
+ agentDirectory,
180
+ ] = await Promise.all([
181
+ readJsonFile(files.state, {}),
182
+ readJsonFile(files.persona, {}),
183
+ readJsonFile(files.delegationPolicy, null),
184
+ readJsonLines(files.auditLog),
185
+ readJsonLines(files.inbox),
186
+ readJsonLines(files.sentDms),
187
+ readJsonFile(files.roomsIndex, []),
188
+ readJsonLines(files.meetingSummaries),
189
+ readJsonLines(files.posts),
190
+ readJsonFile(files.skills, []),
191
+ readJsonLines(files.pendingActions),
192
+ readJsonLines(files.decisions),
193
+ readJsonLines(files.results),
194
+ readJsonLines(files.activities),
195
+ readJsonLines(files.handoff),
196
+ readJsonFile(files.boardState, { readThreads: {} }),
197
+ getAgentDirectory(),
198
+ ]);
199
+
200
+ const data = {
201
+ status,
202
+ rootDir,
203
+ files,
204
+ state,
205
+ persona,
206
+ delegationPolicy,
207
+ auditLog,
208
+ inbox,
209
+ sentDms,
210
+ roomsRaw: Array.isArray(roomsRaw) ? roomsRaw : [],
211
+ rooms: await buildRooms(files, Array.isArray(roomsRaw) ? roomsRaw : [], meetingSummaries, agentDirectory),
212
+ posts,
213
+ skills: Array.isArray(skillsRaw) ? skillsRaw : [],
214
+ pendingActions,
215
+ decisions,
216
+ results,
217
+ activities,
218
+ handoffs,
219
+ boardState: isRecord(boardState) ? boardState : { readThreads: {} },
220
+ agentDirectory,
221
+ };
222
+
223
+ return buildBoardView(data);
224
+ }
225
+
226
+ function buildBoardView(data) {
227
+ const ownAgentId =
228
+ data.state?.communityAgentId ??
229
+ data.persona?.communityAgentId ??
230
+ data.status?.communityAgentId ??
231
+ data.status?.state?.communityAgentId ??
232
+ null;
233
+ const agentDirectory = data.agentDirectory;
234
+ const ownDisplayName = agentLabel(agentDirectory, ownAgentId, data.persona?.displayName ?? ownAgentId ?? "Community Agent");
235
+ const decisions = byActionId(data.decisions);
236
+ const results = byActionId(data.results);
237
+ const threads = buildThreads(data, ownAgentId, agentDirectory);
238
+ const pendingDecisions = data.pendingActions
239
+ .filter((action) => !decisions.has(action.action_id))
240
+ .sort((a, b) => timeValue(b.created_at) - timeValue(a.created_at));
241
+ const acceptedNotApplied = data.pendingActions
242
+ .filter((action) => {
243
+ const decision = decisions.get(action.action_id);
244
+ return decision?.decision === "accepted" && !results.has(action.action_id);
245
+ })
246
+ .sort((a, b) => timeValue(b.created_at) - timeValue(a.created_at));
247
+
248
+ return {
249
+ generatedAt: new Date().toISOString(),
250
+ bridge: {
251
+ offline: Boolean(data.status?.offline),
252
+ source: data.status?.source ?? "unknown",
253
+ error: data.status?.error ?? null,
254
+ connected: data.status?.offline ? false : data.status?.ok !== false,
255
+ },
256
+ agent: {
257
+ communityAgentId: ownAgentId,
258
+ displayName: ownDisplayName,
259
+ workspaceRootDir: data.rootDir,
260
+ lastKeepaliveAt: data.state?.lastKeepaliveAt ?? null,
261
+ online: isRecent(data.state?.lastKeepaliveAt),
262
+ },
263
+ summary: {
264
+ pendingDecisionCount: pendingDecisions.length,
265
+ acceptedNotAppliedCount: acceptedNotApplied.length,
266
+ threadCount: threads.length,
267
+ roomCount: data.rooms.length,
268
+ activityCount: data.activities.length,
269
+ handoffCount: data.handoffs.length,
270
+ },
271
+ authorization: buildAuthorizationSummary(data.delegationPolicy),
272
+ decisions: pendingDecisions.map((action) => formatAction(action, data, agentDirectory)),
273
+ acceptedNotApplied: acceptedNotApplied.map((action) => formatAction(action, data, agentDirectory)),
274
+ activity: buildActivity(data, agentDirectory),
275
+ threads,
276
+ rooms: data.rooms,
277
+ posts: data.posts
278
+ .slice()
279
+ .sort((a, b) => timeValue(b.created_at) - timeValue(a.created_at))
280
+ .slice(0, 20)
281
+ .map((post) => ({
282
+ id: post.post_id ?? post.envelope_id,
283
+ title: post.payload?.title ?? post.title ?? "Untitled",
284
+ body: post.payload?.body ?? post.body ?? "",
285
+ createdAt: post.created_at ?? null,
286
+ intent: post.payload?.intent ?? post.intent ?? null,
287
+ })),
288
+ skillOffers: data.skills
289
+ .slice()
290
+ .sort((a, b) => timeValue(b.created_at) - timeValue(a.created_at))
291
+ .slice(0, 20)
292
+ .map((offer) => ({
293
+ offerId: offer.offer_id,
294
+ name: offer.name ?? offer.skill_slug,
295
+ skillSlug: offer.skill_slug,
296
+ source: offer.source ?? "unknown",
297
+ status: offer.status ?? "staged",
298
+ riskLevel: offer.risk_level ?? "medium",
299
+ description: offer.recommendation_reason ?? offer.description ?? "",
300
+ createdAt: offer.created_at ?? null,
301
+ })),
302
+ debug: {
303
+ rootDir: data.rootDir,
304
+ counts: {
305
+ inbox: data.inbox.length,
306
+ sentDms: data.sentDms.length,
307
+ pendingActions: data.pendingActions.length,
308
+ decisions: data.decisions.length,
309
+ auditLog: data.auditLog.length,
310
+ },
311
+ },
312
+ };
313
+ }
314
+
315
+ function buildThreads(data, ownAgentId, agentDirectory) {
316
+ const readThreads = isRecord(data.boardState?.readThreads) ? data.boardState.readThreads : {};
317
+ const messages = [
318
+ ...data.inbox.map((dm) => ({
319
+ id: dm.dm_id,
320
+ envelopeId: dm.envelope_id,
321
+ peerAgentId: dm.sender_agent_id,
322
+ speaker: "peer",
323
+ message: dm.message,
324
+ intent: dm.intent ?? null,
325
+ createdAt: dm.created_at,
326
+ })),
327
+ ...data.sentDms.map((dm) => {
328
+ const payload = dm.payload ?? {};
329
+ const intent = dm.intent ?? payload.intent ?? null;
330
+ const isAutoReceipt = intent === "dm.auto_receipt" || dm.action === "dm.auto_receipt";
331
+ return {
332
+ id: dm.dm_id ?? dm.envelope_id,
333
+ envelopeId: dm.envelope_id,
334
+ peerAgentId: dm.recipient_agent_id ?? dm.target_agent_id ?? dm.target?.agent_id,
335
+ speaker: isAutoReceipt ? "agent" : "user",
336
+ message: dm.message ?? payload.message,
337
+ intent,
338
+ createdAt: dm.created_at,
339
+ };
340
+ }),
341
+ ].filter((message) => message.peerAgentId && message.message);
342
+
343
+ const groups = new Map();
344
+ for (const message of messages) {
345
+ const peer = message.peerAgentId;
346
+ const thread =
347
+ groups.get(peer) ??
348
+ {
349
+ peerAgentId: peer,
350
+ displayName: agentLabel(agentDirectory, peer, peer),
351
+ messages: [],
352
+ unreadCount: 0,
353
+ lastMessageAt: null,
354
+ lastMessagePreview: "",
355
+ };
356
+ thread.messages.push(message);
357
+ groups.set(peer, thread);
358
+ }
359
+
360
+ return [...groups.values()]
361
+ .map((thread) => {
362
+ thread.messages.sort((a, b) => timeValue(a.createdAt) - timeValue(b.createdAt));
363
+ const last = thread.messages.at(-1);
364
+ const readAt = readThreads[thread.peerAgentId] ?? null;
365
+ thread.unreadCount = thread.messages.filter(
366
+ (message) => message.speaker === "peer" && timeValue(message.createdAt) > timeValue(readAt),
367
+ ).length;
368
+ thread.lastMessageAt = last?.createdAt ?? null;
369
+ thread.lastMessagePreview = last?.message ?? "";
370
+ thread.ownAgentId = ownAgentId;
371
+ return thread;
372
+ })
373
+ .sort((a, b) => timeValue(b.lastMessageAt) - timeValue(a.lastMessageAt));
374
+ }
375
+
376
+ async function buildRooms(files, rooms, meetingSummaries, agentDirectory) {
377
+ const formatted = [];
378
+ const summariesByRoom = new Map();
379
+ for (const summary of Array.isArray(meetingSummaries) ? meetingSummaries : []) {
380
+ if (!summary?.room_id) continue;
381
+ const existing = summariesByRoom.get(summary.room_id);
382
+ if (!existing || timeValue(summary.confirmed_at ?? summary.created_at) > timeValue(existing.confirmed_at ?? existing.created_at)) {
383
+ summariesByRoom.set(summary.room_id, summary);
384
+ }
385
+ }
386
+ for (const room of rooms) {
387
+ if (!room?.room_id) continue;
388
+ const messages = await readJsonLines(path.join(files.roomsDir, `${safeFileSegment(room.room_id)}.jsonl`));
389
+ const roomMessages = messages
390
+ .filter((message) => message?.room_id === room.room_id && typeof message.message === "string")
391
+ .sort((a, b) => timeValue(a.created_at) - timeValue(b.created_at))
392
+ .map((message) => ({
393
+ id: message.message_id ?? message.delivery_id ?? message.envelope_id,
394
+ envelopeId: message.envelope_id,
395
+ senderAgentId: message.sender_agent_id,
396
+ senderDisplayName: agentLabel(agentDirectory, message.sender_agent_id, message.sender_agent_id),
397
+ speakerKind: message.speaker_kind === "agent" ? "agent" : "human",
398
+ speakerLabel: message.speaker_label ?? null,
399
+ message: message.message,
400
+ createdAt: message.created_at,
401
+ }));
402
+ const last = roomMessages.at(-1);
403
+ formatted.push({
404
+ roomId: room.room_id,
405
+ topic: room.topic ?? "Untitled room",
406
+ members: Array.isArray(room.members) ? room.members : [],
407
+ memberLabels: Array.isArray(room.members)
408
+ ? room.members.map((member) => agentLabel(agentDirectory, member, member))
409
+ : [],
410
+ status: room.status ?? "open",
411
+ sourceNegotiationId: room.source_negotiation_id ?? null,
412
+ createdAt: room.created_at ?? null,
413
+ updatedAt: room.updated_at ?? last?.createdAt ?? null,
414
+ lastMessageAt: last?.createdAt ?? null,
415
+ lastMessagePreview: last?.message ?? "",
416
+ summary: summariesByRoom.get(room.room_id) ?? null,
417
+ messages: roomMessages,
418
+ });
419
+ }
420
+
421
+ return formatted.sort((a, b) => timeValue(b.lastMessageAt ?? b.updatedAt) - timeValue(a.lastMessageAt ?? a.updatedAt));
422
+ }
423
+
424
+ function buildActivity(data, agentDirectory) {
425
+ const auditActivities = data.activities.map((activity) => ({
426
+ id: activity.activity_id ?? activity.audit_id ?? `${activity.kind}:${activity.created_at}`,
427
+ kind: activity.kind ?? "activity",
428
+ riskLevel: activity.risk_level ?? "low",
429
+ title: activity.summary ?? activity.kind ?? "Activity",
430
+ body: activity.payload ? summarizePayload(activity.payload) : activity.summary ?? "",
431
+ createdAt: activity.created_at,
432
+ }));
433
+ const auditLog = data.auditLog
434
+ .filter((record) => record.action !== "policy_decision")
435
+ .map((record) => ({
436
+ id: record.audit_id,
437
+ kind: record.action ?? record.kind ?? "audit",
438
+ riskLevel: record.decision === "ask" ? "medium" : "low",
439
+ title: record.action ?? record.kind ?? "Audit",
440
+ body: record.reason ?? record.result ?? record.target_agent_id ?? "",
441
+ createdAt: record.created_at,
442
+ }));
443
+ const skillOffers = data.skills.map((offer) => ({
444
+ id: offer.offer_id,
445
+ kind: "skill.offer",
446
+ riskLevel: offer.risk_level ?? "medium",
447
+ title: `${offer.name ?? offer.skill_slug} recommended`,
448
+ body: offer.recommendation_reason ?? offer.description ?? "",
449
+ createdAt: offer.created_at,
450
+ }));
451
+ const sent = data.sentDms.map((dm) => ({
452
+ id: dm.dm_id ?? dm.envelope_id,
453
+ kind: dm.intent === "dm.auto_receipt" || dm.payload?.intent === "dm.auto_receipt" ? "dm.auto_receipt" : "dm.sent",
454
+ riskLevel: "low",
455
+ title:
456
+ dm.intent === "dm.auto_receipt" || dm.payload?.intent === "dm.auto_receipt"
457
+ ? "Auto receipt sent"
458
+ : `Message sent to ${agentLabel(agentDirectory, dm.recipient_agent_id ?? dm.target_agent_id, "agent")}`,
459
+ body: dm.message ?? dm.payload?.message ?? "",
460
+ createdAt: dm.created_at,
461
+ }));
462
+
463
+ return [...auditActivities, ...auditLog, ...skillOffers, ...sent]
464
+ .filter((activity) => activity.createdAt)
465
+ .sort((a, b) => timeValue(b.createdAt) - timeValue(a.createdAt))
466
+ .slice(0, 40);
467
+ }
468
+
469
+ function formatAction(action, data, agentDirectory) {
470
+ const peerAgentId = action.payload?.source_agent_id ?? action.payload?.sender_agent_id ?? action.payload?.target_agent_id ?? null;
471
+ const sourceEventId = action.source_event_id ?? action.payload?.dm_id ?? action.payload?.envelope_id ?? null;
472
+ const sourceMessage =
473
+ data.inbox.find((dm) => dm.dm_id === sourceEventId || dm.envelope_id === sourceEventId) ??
474
+ data.inbox.find((dm) => dm.sender_agent_id === peerAgentId);
475
+ return {
476
+ actionId: action.action_id,
477
+ kind: action.kind,
478
+ title: action.title ?? action.kind ?? "Pending action",
479
+ summary: action.summary ?? "",
480
+ createdAt: action.created_at ?? null,
481
+ riskLevel: action.payload?.risk_level ?? (action.kind === "install_skill" ? "medium" : "medium"),
482
+ peerAgentId,
483
+ peerDisplayName: agentLabel(agentDirectory, peerAgentId, peerAgentId),
484
+ sourceMessage: sourceMessage
485
+ ? {
486
+ dmId: sourceMessage.dm_id,
487
+ envelopeId: sourceMessage.envelope_id,
488
+ message: sourceMessage.message,
489
+ createdAt: sourceMessage.created_at,
490
+ }
491
+ : null,
492
+ skillSlug: action.payload?.skill_slug ?? null,
493
+ roomId: action.payload?.room_id ?? null,
494
+ todos: Array.isArray(action.payload?.todos) ? action.payload.todos : [],
495
+ };
496
+ }
497
+
498
+ function buildAuthorizationSummary(policy) {
499
+ const rules = Array.isArray(policy?.rules) ? policy.rules : [];
500
+ const byDecision = (decision) => rules.filter((rule) => rule.decision === decision).map((rule) => rule.action);
501
+ return {
502
+ protocolVersion: policy?.protocolVersion ?? "unknown",
503
+ automatic: byDecision("allow"),
504
+ ask: byDecision("ask"),
505
+ blocked: byDecision("deny"),
506
+ autoReceipt: {
507
+ enabled: Boolean(policy?.autoReceipt?.enabled),
508
+ message: policy?.autoReceipt?.message ?? null,
509
+ },
510
+ };
511
+ }
512
+
513
+ async function markThreadRead(rootDir, peerAgentId) {
514
+ const files = workspaceFiles(rootDir);
515
+ const boardState = await readJsonFile(files.boardState, { readThreads: {} });
516
+ const next = isRecord(boardState) ? boardState : { readThreads: {} };
517
+ next.readThreads = isRecord(next.readThreads) ? next.readThreads : {};
518
+ next.readThreads[peerAgentId] = new Date().toISOString();
519
+ await writeJsonFile(files.boardState, next);
520
+ cache.delete("board");
521
+ }
522
+
523
+ async function parseJsonBody(req) {
524
+ const chunks = [];
525
+ for await (const chunk of req) {
526
+ chunks.push(Buffer.from(chunk));
527
+ }
528
+ const text = Buffer.concat(chunks).toString("utf8");
529
+ if (!text.trim()) return {};
530
+ return JSON.parse(text);
531
+ }
532
+
533
+ function byActionId(records) {
534
+ return new Map(records.map((record) => [record.action_id, record]));
535
+ }
536
+
537
+ function agentLabel(agentDirectory, agentId, fallback = "Unknown Agent") {
538
+ if (!agentId) return fallback;
539
+ const item = agentDirectory.get(agentId);
540
+ return item?.display_name ?? fallback ?? agentId;
541
+ }
542
+
543
+ function isRecent(value, maxAgeMs = 90_000) {
544
+ if (!value) return false;
545
+ const time = Date.parse(value);
546
+ return Number.isFinite(time) && Date.now() - time <= maxAgeMs;
547
+ }
548
+
549
+ function timeValue(value) {
550
+ const time = Date.parse(value ?? "");
551
+ return Number.isFinite(time) ? time : 0;
552
+ }
553
+
554
+ function summarizePayload(payload) {
555
+ if (!isRecord(payload)) return "";
556
+ const message = payload.message ?? payload.summary ?? payload.reason ?? payload.skill_slug;
557
+ return typeof message === "string" ? message : JSON.stringify(payload);
558
+ }
559
+
560
+ function safeFileSegment(value) {
561
+ return String(value).replace(/[^a-zA-Z0-9._-]+/g, "_");
562
+ }
563
+
564
+ function isRecord(value) {
565
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
566
+ }
567
+
568
+ function json(res, status, payload) {
569
+ res.writeHead(status, {
570
+ "Content-Type": "application/json; charset=utf-8",
571
+ "Cache-Control": "no-store",
572
+ });
573
+ res.end(`${JSON.stringify(payload, null, 2)}\n`);
574
+ }
575
+
576
+ function html(res, status, payload) {
577
+ res.writeHead(status, {
578
+ "Content-Type": "text/html; charset=utf-8",
579
+ "Cache-Control": "no-store",
580
+ });
581
+ res.end(payload);
582
+ }
583
+
584
+ async function staticFile(res, fileName) {
585
+ const safeName = path.basename(fileName);
586
+ const filePath = path.join(ASSET_DIR, safeName);
587
+ const contentType = safeName.endsWith(".css")
588
+ ? "text/css; charset=utf-8"
589
+ : safeName.endsWith(".js")
590
+ ? "application/javascript; charset=utf-8"
591
+ : "application/octet-stream";
592
+ try {
593
+ const body = await readFile(filePath);
594
+ res.writeHead(200, {
595
+ "Content-Type": contentType,
596
+ "Cache-Control": "no-store",
597
+ });
598
+ res.end(body);
599
+ } catch {
600
+ json(res, 404, { error: "asset not found", asset: safeName, assetDir: ASSET_DIR });
601
+ }
602
+ }
603
+
604
+ function renderAppShell() {
605
+ return `<!doctype html>
606
+ <html lang="zh-CN">
607
+ <head>
608
+ <meta charset="utf-8" />
609
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
610
+ <title>AgentComm Delegation Console</title>
611
+ <link rel="stylesheet" href="/assets/app.css" />
612
+ </head>
613
+ <body>
614
+ <div id="app"></div>
615
+ <script type="module" src="/assets/app.js"></script>
616
+ </body>
617
+ </html>`;
618
+ }
619
+
620
+ const server = http.createServer(async (req, res) => {
621
+ const url = new URL(req.url ?? "/", `http://${HOST}:${PORT}`);
622
+ const method = req.method ?? "GET";
623
+
624
+ try {
625
+ if (method === "GET" && url.pathname === "/") {
626
+ html(res, 200, renderAppShell());
627
+ return;
628
+ }
629
+
630
+ const assetMatch = url.pathname.match(/^\/assets\/([^/]+)$/);
631
+ if (method === "GET" && assetMatch) {
632
+ await staticFile(res, decodeURIComponent(assetMatch[1]));
633
+ return;
634
+ }
635
+
636
+ if (method === "GET" && url.pathname === "/api/board") {
637
+ json(res, 200, await loadBoardData());
638
+ return;
639
+ }
640
+
641
+ if (method === "GET" && url.pathname === "/healthz") {
642
+ const data = await loadBoardData();
643
+ json(res, 200, { ok: true, bridge: data.bridge, agent: data.agent });
644
+ return;
645
+ }
646
+
647
+ const readMatch = url.pathname.match(/^\/api\/threads\/([^/]+)\/read$/);
648
+ if (method === "POST" && readMatch) {
649
+ const data = await loadBoardData();
650
+ await markThreadRead(data.agent.workspaceRootDir, decodeURIComponent(readMatch[1]));
651
+ json(res, 200, { ok: true });
652
+ return;
653
+ }
654
+
655
+ const sendMatch = url.pathname.match(/^\/api\/conversations\/([^/]+)\/send$/);
656
+ if (method === "POST" && sendMatch) {
657
+ const peerAgentId = decodeURIComponent(sendMatch[1]);
658
+ const body = await parseJsonBody(req);
659
+ const message = typeof body.message === "string" ? body.message.trim() : "";
660
+ if (!message) {
661
+ json(res, 400, { error: "message is required" });
662
+ return;
663
+ }
664
+ json(
665
+ res,
666
+ 200,
667
+ await callGateway("communityBridge.dmSend", {
668
+ targetAgentId: peerAgentId,
669
+ message,
670
+ intent: "user-board.dm.compose",
671
+ }),
672
+ );
673
+ cache.clear();
674
+ return;
675
+ }
676
+
677
+ const roomSendMatch = url.pathname.match(/^\/api\/rooms\/([^/]+)\/send$/);
678
+ if (method === "POST" && roomSendMatch) {
679
+ const roomId = decodeURIComponent(roomSendMatch[1]);
680
+ const body = await parseJsonBody(req);
681
+ const message = typeof body.message === "string" ? body.message.trim() : "";
682
+ if (!message) {
683
+ json(res, 400, { error: "message is required" });
684
+ return;
685
+ }
686
+ json(
687
+ res,
688
+ 200,
689
+ await callGateway("communityBridge.roomSend", {
690
+ roomId,
691
+ message,
692
+ speakerKind: "human",
693
+ intent: "user-board.room.compose",
694
+ }),
695
+ );
696
+ cache.clear();
697
+ return;
698
+ }
699
+
700
+ const actionApplyMatch = url.pathname.match(/^\/api\/actions\/([^/]+)\/apply$/);
701
+ if (method === "POST" && actionApplyMatch) {
702
+ json(res, 200, await callGateway("communityBridge.applyAcceptedAction", { actionId: decodeURIComponent(actionApplyMatch[1]) }));
703
+ cache.clear();
704
+ return;
705
+ }
706
+
707
+ const actionMatch = url.pathname.match(/^\/api\/actions\/([^/]+)$/);
708
+ if (method === "POST" && actionMatch) {
709
+ const actionId = decodeURIComponent(actionMatch[1]);
710
+ const body = await parseJsonBody(req);
711
+ if (body.decision === "reject") {
712
+ json(
713
+ res,
714
+ 200,
715
+ await callGateway("communityBridge.rejectAction", {
716
+ actionId,
717
+ reason: "rejected from local user board",
718
+ decidedBy: "user",
719
+ }),
720
+ );
721
+ cache.clear();
722
+ return;
723
+ }
724
+
725
+ const data = await loadBoardData();
726
+ const action = data.decisions.find((item) => item.actionId === actionId);
727
+ if (action?.kind === "reply_to_dm") {
728
+ const message = typeof body.message === "string" ? body.message.trim() : "";
729
+ if (!message) {
730
+ json(res, 400, { error: "reply message is required" });
731
+ return;
732
+ }
733
+ await callGateway("communityBridge.dmSend", {
734
+ targetAgentId: action.peerAgentId,
735
+ message,
736
+ intent: "user-board.dm.reply",
737
+ });
738
+ }
739
+ const result = await callGateway("communityBridge.confirmAction", {
740
+ actionId,
741
+ reason: action?.kind === "reply_to_dm" ? "reply sent from local user board" : "confirmed from local user board",
742
+ decidedBy: "user",
743
+ });
744
+ if (action?.kind === "meeting_summary") {
745
+ await callGateway("communityBridge.applyAcceptedAction", { actionId });
746
+ }
747
+ cache.clear();
748
+ json(res, 200, result);
749
+ return;
750
+ }
751
+
752
+ json(res, 404, { error: "not found", path: url.pathname });
753
+ } catch (error) {
754
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
755
+ }
756
+ });
757
+
758
+ server.listen(PORT, HOST, () => {
759
+ console.log(`[${new Date().toISOString()}] AgentComm local user board listening on http://${HOST}:${PORT}`);
760
+ });