@feynmanzhang/open-party 0.1.6 → 0.1.8

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.
Files changed (31) hide show
  1. package/README.md +138 -0
  2. package/dist/claude-code/{open-party-0.1.6 → open-party-0.1.8}/.claude-plugin/plugin.json +1 -1
  3. package/dist/claude-code/open-party-0.1.8/BUILD_INFO.json +6 -0
  4. package/dist/claude-code/open-party-0.1.8/dist/dispatcher.js +187 -0
  5. package/dist/claude-code/{open-party-0.1.6 → open-party-0.1.8}/dist/hook-handler.js +58 -73
  6. package/dist/claude-code/{open-party-0.1.6 → open-party-0.1.8}/dist/mcp-server.js +552 -364
  7. package/dist/claude-code/{open-party-0.1.6 → open-party-0.1.8}/dist/party-server.js +426 -1657
  8. package/dist/claude-code/{open-party-0.1.6 → open-party-0.1.8}/hooks/hooks.json +39 -50
  9. package/dist/claude-code/{open-party-0.1.6 → open-party-0.1.8}/package.json +1 -1
  10. package/dist/claude-code/{open-party-0.1.6 → open-party-0.1.8}/skills/open-party/SKILL.md +39 -21
  11. package/dist/cli/index.js +1534 -2647
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/party-server.js +426 -1657
  14. package/dist/party-server.js.map +1 -1
  15. package/package.json +10 -13
  16. package/dist/claude-code/open-party-0.1.6/BUILD_INFO.json +0 -6
  17. package/dist/openclaw/open-party-0.1.5/BUILD_INFO.json +0 -6
  18. package/dist/openclaw/open-party-0.1.5/SKILL.md +0 -127
  19. package/dist/openclaw/open-party-0.1.5/dist/index.js +0 -550
  20. package/dist/openclaw/open-party-0.1.5/dist/party-server.js +0 -5502
  21. package/dist/openclaw/open-party-0.1.5/openclaw.plugin.json +0 -28
  22. package/dist/openclaw/open-party-0.1.5/package.json +0 -12
  23. package/dist/openclaw/open-party-0.1.5/skills/open-party/SKILL.md +0 -90
  24. package/dist/openclaw/open-party-0.1.6/BUILD_INFO.json +0 -6
  25. package/dist/openclaw/open-party-0.1.6/SKILL.md +0 -127
  26. package/dist/openclaw/open-party-0.1.6/dist/index.js +0 -550
  27. package/dist/openclaw/open-party-0.1.6/dist/party-server.js +0 -5502
  28. package/dist/openclaw/open-party-0.1.6/openclaw.plugin.json +0 -28
  29. package/dist/openclaw/open-party-0.1.6/package.json +0 -12
  30. package/dist/openclaw/open-party-0.1.6/skills/open-party/SKILL.md +0 -90
  31. /package/dist/claude-code/{open-party-0.1.6 → open-party-0.1.8}/.mcp.json +0 -0
@@ -11,11 +11,12 @@ var __export = (target, all) => {
11
11
  };
12
12
 
13
13
  // src/server/config.ts
14
- var PARTY_PORT, HEARTBEAT_TIMEOUT, CLEANUP_INTERVAL, DISCOVERY_INTERVAL, REMOTE_STALE_FACTOR, PROBE_TIMEOUT;
14
+ var PARTY_PORT, STALE_THRESHOLD, HEARTBEAT_TIMEOUT, CLEANUP_INTERVAL, DISCOVERY_INTERVAL, REMOTE_STALE_FACTOR, PROBE_TIMEOUT;
15
15
  var init_config = __esm({
16
16
  "src/server/config.ts"() {
17
17
  "use strict";
18
18
  PARTY_PORT = parseInt(process.env.PARTY_PORT || "8000", 10);
19
+ STALE_THRESHOLD = parseInt(process.env.STALE_THRESHOLD || "3", 10);
19
20
  HEARTBEAT_TIMEOUT = parseFloat(process.env.HEARTBEAT_TIMEOUT || "60");
20
21
  CLEANUP_INTERVAL = parseFloat(process.env.CLEANUP_INTERVAL || "60");
21
22
  DISCOVERY_INTERVAL = parseFloat(process.env.DISCOVERY_INTERVAL || "20");
@@ -57,10 +58,10 @@ function initLogFile() {
57
58
  }
58
59
  function getLogFilePath() {
59
60
  const d = /* @__PURE__ */ new Date();
60
- const yy = String(d.getFullYear()).slice(2);
61
+ const yyyy = String(d.getFullYear());
61
62
  const mm = String(d.getMonth() + 1).padStart(2, "0");
62
63
  const dd = String(d.getDate()).padStart(2, "0");
63
- return join(LOG_DIR, `${yy}-${mm}-${dd}-open-party.log`);
64
+ return join(LOG_DIR, `${yyyy}-${mm}-${dd}-open-party.log`);
64
65
  }
65
66
  function shouldLog(level) {
66
67
  return LEVEL_ORDER[level] >= LEVEL_ORDER[effectiveLevel];
@@ -76,13 +77,13 @@ ${err.stack}` : "";
76
77
  function format(level, tag, message) {
77
78
  const now = /* @__PURE__ */ new Date();
78
79
  const levelStr = level.toUpperCase().padEnd(5);
79
- const yy = String(now.getFullYear()).slice(2);
80
+ const yyyy = String(now.getFullYear());
80
81
  const mm = String(now.getMonth() + 1).padStart(2, "0");
81
82
  const dd = String(now.getDate()).padStart(2, "0");
82
83
  const hh = String(now.getHours()).padStart(2, "0");
83
84
  const min = String(now.getMinutes()).padStart(2, "0");
84
85
  const ss = String(now.getSeconds()).padStart(2, "0");
85
- const ts = `${yy}-${mm}-${dd} ${hh}:${min}:${ss}`;
86
+ const ts = `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
86
87
  return `${ts} [${levelStr}] [${tag}] ${message}`;
87
88
  }
88
89
  function output(consoleFn, level, tag, message) {
@@ -113,7 +114,8 @@ var init_logger = __esm({
113
114
  output(console.log, "info", tag, data ? `${message} ${JSON.stringify(data)}` : message);
114
115
  },
115
116
  warn(tag, message, data) {
116
- output(console.warn, "warn", tag, data ? `${message} ${JSON.stringify(data)}` : message);
117
+ const detail = data instanceof Error ? `: ${extractError(data)}` : data ? ` ${JSON.stringify(data)}` : "";
118
+ output(console.warn, "warn", tag, message + detail);
117
119
  },
118
120
  error(tag, message, err) {
119
121
  const detail = err ? `: ${extractError(err)}` : "";
@@ -135,26 +137,6 @@ function dataDirPath() {
135
137
  if (pluginData) return join2(pluginData, "data");
136
138
  return join2(homedir2(), ".open-party", "data");
137
139
  }
138
- function runMigrations(raw2) {
139
- const data = raw2;
140
- if (!data || typeof data !== "object") {
141
- throw new Error("Snapshot is not a valid object");
142
- }
143
- const version = typeof data.version === "number" ? data.version : 0;
144
- let snapshot = {
145
- version,
146
- saved_at: typeof data.saved_at === "number" ? data.saved_at : 0,
147
- agents: Array.isArray(data.agents) ? data.agents : [],
148
- history: typeof data.history === "object" && data.history !== null && !Array.isArray(data.history) ? data.history : {}
149
- };
150
- for (const m of MIGRATORS) {
151
- if (m.version > snapshot.version) {
152
- snapshot = m.migrate(snapshot);
153
- snapshot.version = m.version;
154
- }
155
- }
156
- return snapshot;
157
- }
158
140
  function abortableSleep(ms, signal) {
159
141
  return new Promise((resolve, reject) => {
160
142
  const timer = setTimeout(resolve, ms);
@@ -168,24 +150,19 @@ function abortableSleep(ms, signal) {
168
150
  );
169
151
  });
170
152
  }
171
- var CURRENT_SCHEMA_VERSION, SNAPSHOT_FILE, SHUTDOWN_MARKER_FILE, DEFAULT_SNAPSHOT_INTERVAL_MS, DEBOUNCE_MS, MIGRATORS, SnapshotManager;
153
+ var CURRENT_SCHEMA_VERSION, SNAPSHOT_FILE, SHUTDOWN_MARKER_FILE, DEFAULT_SNAPSHOT_INTERVAL_MS, SnapshotManager;
172
154
  var init_persistence = __esm({
173
155
  "src/server/persistence.ts"() {
174
156
  "use strict";
175
157
  init_logger();
176
- CURRENT_SCHEMA_VERSION = 1;
158
+ CURRENT_SCHEMA_VERSION = 2;
177
159
  SNAPSHOT_FILE = "snapshot.json";
178
160
  SHUTDOWN_MARKER_FILE = "shutdown-marker.json";
179
161
  DEFAULT_SNAPSHOT_INTERVAL_MS = 6e4;
180
- DEBOUNCE_MS = 5e3;
181
- MIGRATORS = [
182
- // Future: { version: 2, migrate(snapshot) { ... } },
183
- ];
184
162
  SnapshotManager = class {
185
163
  _dir;
186
164
  _snapshotPath;
187
165
  _markerPath;
188
- _debounceTimer = null;
189
166
  constructor(dataDir) {
190
167
  this._dir = dataDir ?? dataDirPath();
191
168
  this._snapshotPath = join2(this._dir, SNAPSHOT_FILE);
@@ -203,13 +180,13 @@ var init_persistence = __esm({
203
180
  // ------------------------------------------------------------------
204
181
  // Write / Load
205
182
  // ------------------------------------------------------------------
206
- /** Atomically write a snapshot of registry agents and message queue history. */
207
- writeSnapshot(agents, history) {
183
+ /** Atomically write a snapshot of registry agents and ring buffer state. */
184
+ writeSnapshot(agents, buffers) {
208
185
  const snapshot = {
209
186
  version: CURRENT_SCHEMA_VERSION,
210
187
  saved_at: Date.now(),
211
188
  agents,
212
- history
189
+ buffers
213
190
  };
214
191
  const serialized = JSON.stringify(snapshot, null, 2);
215
192
  const tmpPath = this._snapshotPath + ".tmp";
@@ -232,7 +209,15 @@ var init_persistence = __esm({
232
209
  }
233
210
  try {
234
211
  const raw2 = JSON.parse(readFileSync(this._snapshotPath, "utf-8"));
235
- return runMigrations(raw2);
212
+ if (!raw2 || typeof raw2 !== "object") {
213
+ throw new Error("Snapshot is not a valid object");
214
+ }
215
+ return {
216
+ version: typeof raw2.version === "number" ? raw2.version : CURRENT_SCHEMA_VERSION,
217
+ saved_at: typeof raw2.saved_at === "number" ? raw2.saved_at : 0,
218
+ agents: Array.isArray(raw2.agents) ? raw2.agents : [],
219
+ buffers: raw2.buffers && typeof raw2.buffers === "object" && !Array.isArray(raw2.buffers) ? raw2.buffers : {}
220
+ };
236
221
  } catch (error) {
237
222
  logger.warn("Persistence", "Failed to load snapshot (starting fresh)", error);
238
223
  return null;
@@ -259,22 +244,14 @@ var init_persistence = __esm({
259
244
  }
260
245
  return count;
261
246
  }
262
- /** Restore message history into queue. */
263
- hydrateHistory(queue) {
247
+ /** Restore ring buffer state into message queue. */
248
+ hydrateBuffers(queue) {
264
249
  const snapshot = this.loadSnapshot();
265
- if (!snapshot || Object.keys(snapshot.history).length === 0) return 0;
250
+ if (!snapshot || Object.keys(snapshot.buffers).length === 0) return 0;
251
+ queue.restoreBufferSnapshots(snapshot.buffers);
266
252
  let totalEntries = 0;
267
- for (const [agentId, entries] of Object.entries(snapshot.history)) {
268
- for (const entry of entries) {
269
- queue.logToHistory(agentId, entry.direction, {
270
- sender_id: entry.sender_id,
271
- recipient_id: entry.recipient_id,
272
- summary: entry.summary,
273
- content: entry.content,
274
- timestamp: entry.timestamp
275
- });
276
- totalEntries++;
277
- }
253
+ for (const snap of Object.values(snapshot.buffers)) {
254
+ totalEntries += snap.entries.length;
278
255
  }
279
256
  return totalEntries;
280
257
  }
@@ -308,13 +285,13 @@ var init_persistence = __esm({
308
285
  return existsSync2(this._markerPath);
309
286
  }
310
287
  // ------------------------------------------------------------------
311
- // Snapshot loop & debounce
288
+ // Snapshot loop
312
289
  // ------------------------------------------------------------------
313
290
  /**
314
291
  * Start periodic snapshot background loop.
315
292
  * Writes snapshot every `intervalMs` milliseconds until signal is aborted.
316
293
  */
317
- async startSnapshotLoop(signal, getAgents, getHistory, intervalMs = DEFAULT_SNAPSHOT_INTERVAL_MS) {
294
+ async startSnapshotLoop(signal, getAgents, getBuffers, intervalMs = DEFAULT_SNAPSHOT_INTERVAL_MS) {
318
295
  while (!signal.aborted) {
319
296
  try {
320
297
  await abortableSleep(intervalMs, signal);
@@ -324,36 +301,12 @@ var init_persistence = __esm({
324
301
  }
325
302
  if (signal.aborted) break;
326
303
  try {
327
- this.writeSnapshot(getAgents(), getHistory());
304
+ this.writeSnapshot(getAgents(), getBuffers());
328
305
  } catch (error) {
329
306
  logger.warn("Persistence", "Periodic snapshot failed", error);
330
307
  }
331
308
  }
332
309
  }
333
- /**
334
- * Schedule a debounced snapshot write.
335
- * Multiple calls within DEBOUNCE_MS window are coalesced into one write.
336
- */
337
- scheduleSnapshot(getAgents, getHistory) {
338
- if (this._debounceTimer !== null) {
339
- clearTimeout(this._debounceTimer);
340
- }
341
- this._debounceTimer = setTimeout(() => {
342
- this._debounceTimer = null;
343
- try {
344
- this.writeSnapshot(getAgents(), getHistory());
345
- } catch (error) {
346
- logger.warn("Persistence", "Debounced snapshot failed", error);
347
- }
348
- }, DEBOUNCE_MS);
349
- }
350
- /** Cancel pending debounced snapshot (called during shutdown). */
351
- cancelDebounce() {
352
- if (this._debounceTimer !== null) {
353
- clearTimeout(this._debounceTimer);
354
- this._debounceTimer = null;
355
- }
356
- }
357
310
  };
358
311
  }
359
312
  });
@@ -468,243 +421,293 @@ function getTailscaleIps() {
468
421
  }
469
422
  return [];
470
423
  }
471
- function execWithSudoFallback(cmd, timeout = 15e3) {
472
- try {
473
- return runExec(cmd, timeout);
474
- } catch (exc) {
475
- const err = exc;
476
- const stderrLower = (err.stderr ?? "").toLowerCase();
477
- if (PERMISSION_KEYWORDS.some((kw) => stderrLower.includes(kw))) {
478
- try {
479
- return runExec(["sudo", "-n", ...cmd], timeout);
480
- } catch {
481
- }
482
- }
483
- throw exc;
484
- }
485
- }
486
- function joinTailnet(authKey, timeout = 3e4) {
487
- const binary = getTailscaleBinary();
488
- try {
489
- const output2 = execWithSudoFallback(
490
- [binary, "up", "--authkey", authKey],
491
- timeout
492
- );
493
- return { success: true, output: output2.trim() };
494
- } catch (e) {
495
- const err = e;
496
- return { success: false, output: (err.stderr ?? err.message ?? "").trim() };
497
- }
498
- }
499
- function getTailscaleInstallationStatus() {
500
- const binary = findTailscaleBinary();
501
- if (!binary) {
502
- return { state: "not_installed", platform: process.platform };
503
- }
504
- try {
505
- const status = readTailscaleStatus();
506
- const self = status.Self ?? {};
507
- const online = self.Online === true;
508
- if (online) {
509
- const ips = self.TailscaleIPs;
510
- const dns = self.DNSName?.replace(/\.$/, "");
511
- return {
512
- state: "connected",
513
- binary,
514
- tailscale_ip: ips?.[0] ?? "127.0.0.1",
515
- hostname: dns ?? null
516
- };
517
- }
518
- return { state: "not_connected", binary };
519
- } catch (error) {
520
- console.error("[Tailscale] Failed to get installation status:", error);
521
- return {
522
- state: "not_connected",
523
- binary,
524
- error: error instanceof Error ? error.message : String(error)
525
- };
526
- }
527
- }
528
- function resetTailscaleBinaryCache() {
529
- cachedBinary = null;
530
- }
531
- function logoutTailscale(timeout = 15e3) {
532
- const binary = getTailscaleBinary();
533
- try {
534
- const output2 = runExec([binary, "logout"], timeout);
535
- resetTailscaleBinaryCache();
536
- return { success: true, output: output2.trim() };
537
- } catch (e) {
538
- const err = e;
539
- return { success: false, output: (err.stderr ?? err.message ?? "").trim() };
540
- }
541
- }
542
- function startInteractiveLogin() {
543
- const binary = getTailscaleBinary();
544
- const child = nodeSpawn(binary, ["login"], {
545
- stdio: ["pipe", "pipe", "pipe"],
546
- windowsHide: true
547
- });
548
- const urlRegex = /https:\/\/login\.tailscale\.com\/a\/[^\s]+/;
549
- const promise = new Promise((resolve) => {
550
- let stdout = "";
551
- let resolved = false;
552
- const done = (result) => {
553
- if (resolved) return;
554
- resolved = true;
555
- resolve(result);
556
- };
557
- child.stdout?.on("data", (data) => {
558
- stdout += data.toString();
559
- const match2 = stdout.match(urlRegex);
560
- if (match2) {
561
- done({ success: true, url: match2[0], output: stdout.trim() });
562
- }
563
- });
564
- child.stderr?.on("data", (data) => {
565
- stdout += data.toString();
566
- });
567
- child.on("close", (code) => {
568
- if (code === 0) {
569
- done({ success: true, output: stdout.trim() });
570
- } else {
571
- done({ success: false, output: stdout.trim() || `Exited with code ${code}` });
572
- }
573
- });
574
- child.on("error", (err) => {
575
- done({ success: false, output: err.message });
576
- });
577
- setTimeout(() => {
578
- done({ success: false, output: "Timeout waiting for login URL" });
579
- try {
580
- child.kill();
581
- } catch {
582
- }
583
- }, 3e4);
584
- });
585
- return { promise, process: child };
586
- }
587
- function getInstallInstructions(platform) {
588
- switch (platform) {
589
- case "linux":
590
- return {
591
- os: "linux",
592
- download_url: "https://tailscale.com/download/linux",
593
- commands: ["curl -fsSL https://tailscale.com/install.sh | sh"],
594
- needs_sudo: true
595
- };
596
- case "darwin":
597
- return {
598
- os: "macOS",
599
- download_url: "https://tailscale.com/download/mac",
600
- commands: ["brew install tailscale"],
601
- needs_sudo: false
602
- };
603
- case "win32":
604
- return {
605
- os: "windows",
606
- download_url: "https://tailscale.com/download/windows",
607
- commands: ["winget install Tailscale.Tailscale"],
608
- needs_sudo: false
609
- };
610
- default:
611
- return {
612
- os: platform,
613
- download_url: "https://tailscale.com/download/",
614
- commands: [],
615
- needs_sudo: false
616
- };
617
- }
618
- }
619
- var cachedBinary, PERMISSION_KEYWORDS;
424
+ var cachedBinary;
620
425
  var init_tailscale = __esm({
621
426
  "src/infra/tailscale.ts"() {
622
427
  "use strict";
623
428
  cachedBinary = null;
624
- PERMISSION_KEYWORDS = [
625
- "permission denied",
626
- "access denied",
627
- "operation not permitted",
628
- "not permitted",
629
- "requires root",
630
- "must be run as root",
631
- "must be run with sudo",
632
- "requires sudo",
633
- "need sudo"
634
- ];
429
+ }
430
+ });
431
+
432
+ // src/server/ring-buffer.ts
433
+ var DEFAULT_CAPACITY, AgentRingBuffer;
434
+ var init_ring_buffer = __esm({
435
+ "src/server/ring-buffer.ts"() {
436
+ "use strict";
437
+ DEFAULT_CAPACITY = 200;
438
+ AgentRingBuffer = class {
439
+ _buffer;
440
+ _capacity;
441
+ _head = 0;
442
+ // next write position (0 ~ capacity-1)
443
+ _nextSeq = 1;
444
+ // next sequence number to assign
445
+ _count = 0;
446
+ // valid entries currently in buffer
447
+ _cursor = 0;
448
+ // server-side read cursor (last consumed seq)
449
+ constructor(capacity = DEFAULT_CAPACITY) {
450
+ this._capacity = Math.max(1, capacity);
451
+ this._buffer = new Array(this._capacity);
452
+ }
453
+ // ------------------------------------------------------------------
454
+ // Write
455
+ // ------------------------------------------------------------------
456
+ /** Write an entry to the buffer. Returns the assigned sequence number. */
457
+ write(direction, envelope) {
458
+ const seq = this._nextSeq++;
459
+ const entry = {
460
+ seq,
461
+ direction,
462
+ sender_id: envelope.sender_id,
463
+ recipient_id: envelope.recipient_id,
464
+ summary: envelope.summary,
465
+ content: envelope.content,
466
+ timestamp: envelope.timestamp ?? Date.now() / 1e3
467
+ };
468
+ this._buffer[this._head] = entry;
469
+ this._head = (this._head + 1) % this._capacity;
470
+ if (this._count < this._capacity) {
471
+ this._count++;
472
+ }
473
+ return seq;
474
+ }
475
+ // ------------------------------------------------------------------
476
+ // Read (cursor-based — dequeue semantics)
477
+ // ------------------------------------------------------------------
478
+ /**
479
+ * Read up to maxCount entries after the cursor, then advance the cursor.
480
+ * Non-destructive: entries remain in the buffer for history queries.
481
+ */
482
+ dequeue(maxCount = 50) {
483
+ const unread = this._readSince(this._cursor);
484
+ if (unread.length === 0) return [];
485
+ const taken = unread.slice(0, maxCount);
486
+ this._cursor = taken[taken.length - 1].seq;
487
+ return taken;
488
+ }
489
+ /**
490
+ * Like dequeue(), but only returns 'received' entries.
491
+ * Cursor advances to the last returned 'received' entry's seq.
492
+ * Used for inbox semantics — agents only see incoming messages.
493
+ */
494
+ dequeueReceived(maxCount = 50) {
495
+ const all = this._readSince(this._cursor);
496
+ if (all.length === 0) return [];
497
+ const received = all.filter((e) => e.direction === "received").slice(0, maxCount);
498
+ if (received.length === 0) return [];
499
+ this._cursor = received[received.length - 1].seq;
500
+ return received;
501
+ }
502
+ /** Number of unread entries (next_seq - cursor). */
503
+ unreadCount() {
504
+ const diff = this._nextSeq - 1 - this._cursor;
505
+ return Math.max(0, diff);
506
+ }
507
+ /** Number of unread 'received' entries after cursor. */
508
+ unreadReceivedCount() {
509
+ const all = this._readSince(this._cursor);
510
+ let count = 0;
511
+ for (const e of all) {
512
+ if (e.direction === "received") count++;
513
+ }
514
+ return count;
515
+ }
516
+ // ------------------------------------------------------------------
517
+ // Read (non-destructive — no cursor advance)
518
+ // ------------------------------------------------------------------
519
+ /** Read all entries with seq > sinceSeq. Does NOT advance cursor. */
520
+ readSince(sinceSeq) {
521
+ return this._readSince(sinceSeq);
522
+ }
523
+ /** Get the most recent N entries regardless of cursor. */
524
+ getRecent(limit = 20) {
525
+ const all = this._allSorted();
526
+ return all.slice(-limit);
527
+ }
528
+ /** Get total number of valid entries in the buffer. */
529
+ get count() {
530
+ return this._count;
531
+ }
532
+ // ------------------------------------------------------------------
533
+ // Lifecycle
534
+ // ------------------------------------------------------------------
535
+ /** Clear the buffer and reset cursor. */
536
+ clear() {
537
+ this._buffer.fill(void 0);
538
+ this._head = 0;
539
+ this._nextSeq = 1;
540
+ this._count = 0;
541
+ this._cursor = 0;
542
+ }
543
+ // ------------------------------------------------------------------
544
+ // Snapshot / Restore
545
+ // ------------------------------------------------------------------
546
+ /** Export buffer state for persistence. */
547
+ getSnapshot() {
548
+ return {
549
+ entries: this._allSorted(),
550
+ next_seq: this._nextSeq,
551
+ cursor: this._cursor
552
+ };
553
+ }
554
+ /** Restore buffer from a snapshot. */
555
+ restoreFromSnapshot(snap) {
556
+ this.clear();
557
+ for (const entry of snap.entries) {
558
+ this._buffer[this._head] = entry;
559
+ this._head = (this._head + 1) % this._capacity;
560
+ this._count++;
561
+ }
562
+ this._nextSeq = snap.next_seq;
563
+ this._cursor = snap.cursor;
564
+ }
565
+ /** Get the next sequence number that will be assigned. */
566
+ get nextSeq() {
567
+ return this._nextSeq;
568
+ }
569
+ /** Get the current cursor position. */
570
+ get cursor() {
571
+ return this._cursor;
572
+ }
573
+ // ------------------------------------------------------------------
574
+ // Private helpers
575
+ // ------------------------------------------------------------------
576
+ /**
577
+ * Collect all valid entries sorted by seq.
578
+ * O(count) — scans the ring buffer once.
579
+ */
580
+ _allSorted() {
581
+ const result = [];
582
+ for (let i = 0; i < this._capacity; i++) {
583
+ const entry = this._buffer[i];
584
+ if (entry !== void 0) {
585
+ result.push(entry);
586
+ }
587
+ }
588
+ result.sort((a, b) => a.seq - b.seq);
589
+ return result;
590
+ }
591
+ /**
592
+ * Read entries with seq > sinceSeq.
593
+ * Uses binary search on sorted entries for efficiency.
594
+ */
595
+ _readSince(sinceSeq) {
596
+ const all = this._allSorted();
597
+ let lo = 0;
598
+ let hi = all.length;
599
+ while (lo < hi) {
600
+ const mid = lo + hi >>> 1;
601
+ if (all[mid].seq <= sinceSeq) {
602
+ lo = mid + 1;
603
+ } else {
604
+ hi = mid;
605
+ }
606
+ }
607
+ return all.slice(lo);
608
+ }
609
+ };
635
610
  }
636
611
  });
637
612
 
638
613
  // src/server/message-queue.ts
639
- var HISTORY_CAP, MessageQueue;
614
+ function toEnvelope(e) {
615
+ return {
616
+ sender_id: e.sender_id,
617
+ recipient_id: e.recipient_id,
618
+ summary: e.summary,
619
+ content: e.content,
620
+ timestamp: e.timestamp
621
+ };
622
+ }
623
+ function toHistoryEntry(e) {
624
+ return {
625
+ direction: e.direction,
626
+ sender_id: e.sender_id,
627
+ recipient_id: e.recipient_id,
628
+ summary: e.summary,
629
+ content: e.content,
630
+ timestamp: e.timestamp
631
+ };
632
+ }
633
+ var DEFAULT_CAPACITY2, MessageQueue;
640
634
  var init_message_queue = __esm({
641
635
  "src/server/message-queue.ts"() {
642
636
  "use strict";
643
- HISTORY_CAP = 200;
637
+ init_ring_buffer();
638
+ DEFAULT_CAPACITY2 = 200;
644
639
  MessageQueue = class {
645
- _queues = /* @__PURE__ */ new Map();
646
- _history = /* @__PURE__ */ new Map();
647
- /** Enqueue a message for agentId. Returns the queue length after enqueue. */
648
- enqueue(agentId, envelope) {
649
- let q = this._queues.get(agentId);
650
- if (!q) {
651
- q = [];
652
- this._queues.set(agentId, q);
640
+ _buffers = /* @__PURE__ */ new Map();
641
+ _getOrCreate(agentId) {
642
+ let buf = this._buffers.get(agentId);
643
+ if (!buf) {
644
+ buf = new AgentRingBuffer(DEFAULT_CAPACITY2);
645
+ this._buffers.set(agentId, buf);
653
646
  }
654
- q.push(envelope);
655
- return q.length;
647
+ return buf;
656
648
  }
657
- /** Pop up to maxCount messages for agentId. */
649
+ /** Enqueue a message for agentId. Returns the unread count after enqueue. */
650
+ enqueue(agentId, envelope) {
651
+ const buf = this._getOrCreate(agentId);
652
+ buf.write("received", envelope);
653
+ return buf.unreadReceivedCount();
654
+ }
655
+ /** Pop up to maxCount received messages for agentId (non-destructive, cursor-based). */
658
656
  dequeue(agentId, maxCount = 50) {
659
- const q = this._queues.get(agentId);
660
- if (!q) return [];
661
- return q.splice(0, Math.min(maxCount, q.length));
657
+ const buf = this._buffers.get(agentId);
658
+ if (!buf) return [];
659
+ return buf.dequeueReceived(maxCount).map(toEnvelope);
662
660
  }
663
- /** Return the number of pending messages for agentId. */
661
+ /** Return the number of pending received messages for agentId. */
664
662
  pendingCount(agentId) {
665
- return this._queues.get(agentId)?.length ?? 0;
663
+ return this._buffers.get(agentId)?.unreadReceivedCount() ?? 0;
666
664
  }
667
- /** Clean up queue when agent is removed. */
665
+ /** Clean up buffer when agent is removed. */
668
666
  removeAgent(agentId) {
669
- this._queues.delete(agentId);
667
+ this._buffers.delete(agentId);
670
668
  }
671
- /** Record a message in an agent's history. */
669
+ /** Record a message in an agent's history (writes to the same ring buffer). */
672
670
  logToHistory(agentId, direction, envelope) {
673
- let h = this._history.get(agentId);
674
- if (!h) {
675
- h = [];
676
- this._history.set(agentId, h);
677
- }
678
- h.push({
679
- direction,
680
- sender_id: envelope.sender_id,
681
- recipient_id: envelope.recipient_id,
682
- summary: envelope.summary,
683
- content: envelope.content,
684
- timestamp: envelope.timestamp ?? Date.now() / 1e3
685
- });
686
- if (h.length > HISTORY_CAP) {
687
- this._history.set(agentId, h.slice(-HISTORY_CAP));
688
- }
671
+ const buf = this._getOrCreate(agentId);
672
+ buf.write(direction, envelope);
689
673
  }
690
674
  /** Get recent N history entries for an agent. */
691
675
  getHistory(agentId, limit = 20) {
692
- const h = this._history.get(agentId);
693
- if (!h) return [];
694
- return h.slice(-limit);
676
+ const buf = this._buffers.get(agentId);
677
+ if (!buf) return [];
678
+ return buf.getRecent(limit).map(toHistoryEntry);
695
679
  }
696
- /** Clean up history when agent is removed. */
680
+ /** Clean up history when agent is removed (same as removeAgent — unified storage). */
697
681
  removeAgentHistory(agentId) {
698
- this._history.delete(agentId);
682
+ this._buffers.delete(agentId);
699
683
  }
700
- /** Return a shallow copy of the full history map (for persistence snapshots). */
684
+ /**
685
+ * Return a shallow copy of the full history map (for persistence snapshots).
686
+ * Kept for backward compatibility with persistence v1 consumers.
687
+ */
701
688
  getHistorySnapshot() {
702
689
  const copy = {};
703
- for (const [agentId, entries] of this._history) {
704
- copy[agentId] = [...entries];
690
+ for (const [agentId, buf] of this._buffers) {
691
+ copy[agentId] = buf.getRecent().map(toHistoryEntry);
705
692
  }
706
693
  return copy;
707
694
  }
695
+ /** Return ring buffer snapshots for persistence v2. */
696
+ getBufferSnapshots() {
697
+ const result = {};
698
+ for (const [agentId, buf] of this._buffers) {
699
+ result[agentId] = buf.getSnapshot();
700
+ }
701
+ return result;
702
+ }
703
+ /** Restore buffers from v2 snapshots. */
704
+ restoreBufferSnapshots(snapshots) {
705
+ for (const [agentId, snap] of Object.entries(snapshots)) {
706
+ const buf = new AgentRingBuffer(DEFAULT_CAPACITY2);
707
+ buf.restoreFromSnapshot(snap);
708
+ this._buffers.set(agentId, buf);
709
+ }
710
+ }
708
711
  };
709
712
  }
710
713
  });
@@ -719,7 +722,7 @@ function classifyFetchError(error) {
719
722
  if (error instanceof DOMException && error.name === "AbortError") return null;
720
723
  return null;
721
724
  }
722
- var UNKNOWN, PARTY_SERVER, DEGRADED, SUSPECT, DOWN, NOT_SERVER, MAYBE, MAYBE_MAX_RETRIES, BACKOFF_BASE, BACKOFF_CAP, FAILURE_SUSPECT, FAILURE_DOWN, PeerDiscovery;
725
+ var UNKNOWN, ALIVE, DEAD, MAX_FAILURES, BACKOFF_BASE, BACKOFF_CAP, PeerDiscovery;
723
726
  var init_peer_discovery = __esm({
724
727
  "src/server/peer-discovery.ts"() {
725
728
  "use strict";
@@ -728,17 +731,11 @@ var init_peer_discovery = __esm({
728
731
  init_persistence();
729
732
  init_logger();
730
733
  UNKNOWN = "UNKNOWN";
731
- PARTY_SERVER = "PARTY_SERVER";
732
- DEGRADED = "DEGRADED";
733
- SUSPECT = "SUSPECT";
734
- DOWN = "DOWN";
735
- NOT_SERVER = "NOT_SERVER";
736
- MAYBE = "MAYBE";
737
- MAYBE_MAX_RETRIES = 3;
734
+ ALIVE = "ALIVE";
735
+ DEAD = "DEAD";
736
+ MAX_FAILURES = 3;
738
737
  BACKOFF_BASE = 60;
739
738
  BACKOFF_CAP = 900;
740
- FAILURE_SUSPECT = 2;
741
- FAILURE_DOWN = 3;
742
739
  PeerDiscovery = class {
743
740
  _selfIp;
744
741
  _peers = /* @__PURE__ */ new Map();
@@ -753,10 +750,10 @@ var init_peer_discovery = __esm({
753
750
  getPeerForAgent(agentId) {
754
751
  return this._remoteAgents.get(agentId)?.sourcePeerIp;
755
752
  }
756
- /** Return true if the peer is in a serving state. */
753
+ /** Return true if the peer is alive. */
757
754
  isPeerReachable(peerIp) {
758
755
  const ps = this._peers.get(peerIp);
759
- return ps !== void 0 && (ps.status === PARTY_SERVER || ps.status === DEGRADED || ps.status === SUSPECT);
756
+ return ps !== void 0 && ps.status === ALIVE;
760
757
  }
761
758
  /** Return all remote agents (including unreachable ones). */
762
759
  getAllRemoteAgents() {
@@ -766,7 +763,7 @@ var init_peer_discovery = __esm({
766
763
  getReachableRemoteAgents() {
767
764
  return Array.from(this._remoteAgents.values()).filter((e) => e.reachable).map((e) => e.agentInfo);
768
765
  }
769
- /** Return all known peer states for dashboard consumption. */
766
+ /** Return all known peer states. */
770
767
  getPeerStates() {
771
768
  return Array.from(this._peers.values()).map((ps) => ({
772
769
  ip: ps.ip,
@@ -802,23 +799,19 @@ var init_peer_discovery = __esm({
802
799
  const peerIps = this.getTailscalePeers();
803
800
  for (const ip of peerIps) {
804
801
  if (!this._peers.has(ip)) {
805
- this._peers.set(ip, { ip, status: UNKNOWN, consecutiveFailures: 0, lastProbeAt: 0, backoffUntil: null, maybeRetries: 0 });
802
+ this._peers.set(ip, { ip, status: UNKNOWN, consecutiveFailures: 0, lastProbeAt: 0, backoffUntil: null });
806
803
  }
807
804
  }
808
805
  const now = performance.now() / 1e3;
809
806
  for (const ip of peerIps) {
810
807
  const ps = this._peers.get(ip);
811
- if (ps.status === NOT_SERVER) {
808
+ if (ps.status === DEAD) {
812
809
  if (ps.backoffUntil !== null && now < ps.backoffUntil) continue;
813
810
  ps.status = UNKNOWN;
814
811
  }
815
- if (ps.status === MAYBE && ps.maybeRetries >= MAYBE_MAX_RETRIES) {
816
- this.transition(ps, NOT_SERVER);
817
- continue;
818
- }
819
812
  await this.probePeer(ps);
820
813
  }
821
- this.evictDownAgents();
814
+ this.evictDeadAgents();
822
815
  this.evictStaleAgents();
823
816
  }
824
817
  // ------------------------------------------------------------------
@@ -856,12 +849,10 @@ var init_peer_discovery = __esm({
856
849
  async probePeer(ps) {
857
850
  ps.lastProbeAt = Date.now() / 1e3;
858
851
  const healthy = await this.checkHealth(ps.ip);
859
- if (healthy === null) {
860
- this.handleProbeFailure(ps, true);
861
- } else if (healthy) {
852
+ if (healthy === true) {
862
853
  await this.handleProbeSuccess(ps);
863
- } else {
864
- this.handleProbeFailure(ps, false);
854
+ } else if (healthy === false) {
855
+ this.handleProbeFailure(ps);
865
856
  }
866
857
  }
867
858
  async checkHealth(ip) {
@@ -882,55 +873,24 @@ var init_peer_discovery = __esm({
882
873
  // ------------------------------------------------------------------
883
874
  async handleProbeSuccess(ps) {
884
875
  ps.consecutiveFailures = 0;
885
- ps.maybeRetries = 0;
886
876
  ps.backoffUntil = null;
887
- if (ps.status !== PARTY_SERVER) {
888
- this.transition(ps, PARTY_SERVER);
877
+ if (ps.status !== ALIVE) {
878
+ const old = ps.status;
879
+ ps.status = ALIVE;
880
+ logger.info("Discovery", `Peer ${ps.ip}: ${old} -> ALIVE`);
889
881
  }
890
882
  await this.syncAgents(ps.ip);
891
883
  }
892
- handleProbeFailure(ps, timeout) {
893
- if (ps.status === UNKNOWN || ps.status === MAYBE) {
894
- if (timeout) {
895
- ps.maybeRetries++;
896
- if (ps.maybeRetries >= MAYBE_MAX_RETRIES) {
897
- this.transition(ps, NOT_SERVER);
898
- } else {
899
- this.transition(ps, MAYBE);
900
- }
901
- } else {
902
- this.transition(ps, NOT_SERVER);
884
+ handleProbeFailure(ps) {
885
+ ps.consecutiveFailures++;
886
+ if (ps.consecutiveFailures >= MAX_FAILURES) {
887
+ const old = ps.status;
888
+ ps.status = DEAD;
889
+ if (old !== DEAD) {
890
+ const delay = Math.min(BACKOFF_BASE * Math.pow(2, ps.consecutiveFailures - 1), BACKOFF_CAP);
891
+ ps.backoffUntil = performance.now() / 1e3 + delay;
892
+ logger.info("Discovery", `Peer ${ps.ip}: ${old} -> DEAD (backoff ${delay}s)`);
903
893
  }
904
- } else if (ps.status === PARTY_SERVER) {
905
- ps.consecutiveFailures++;
906
- if (ps.consecutiveFailures >= FAILURE_DOWN) {
907
- this.transition(ps, DOWN);
908
- } else if (ps.consecutiveFailures >= FAILURE_SUSPECT) {
909
- this.transition(ps, SUSPECT);
910
- } else {
911
- this.transition(ps, DEGRADED);
912
- }
913
- } else if (ps.status === DEGRADED || ps.status === SUSPECT) {
914
- ps.consecutiveFailures++;
915
- if (ps.consecutiveFailures >= FAILURE_DOWN) {
916
- this.transition(ps, DOWN);
917
- } else if (ps.status === DEGRADED && ps.consecutiveFailures >= FAILURE_SUSPECT) {
918
- this.transition(ps, SUSPECT);
919
- }
920
- }
921
- }
922
- transition(ps, newStatus) {
923
- const old = ps.status;
924
- ps.status = newStatus;
925
- if (old !== newStatus) {
926
- logger.info("Discovery", `Peer ${ps.ip}: ${old} -> ${newStatus}`);
927
- }
928
- if (newStatus === NOT_SERVER) {
929
- const retries = ps.maybeRetries > 0 ? ps.maybeRetries : 1;
930
- const delay = Math.min(BACKOFF_BASE * Math.pow(2, retries - 1), BACKOFF_CAP);
931
- ps.backoffUntil = performance.now() / 1e3 + delay;
932
- }
933
- if (newStatus === DOWN) {
934
894
  for (const entry of this._remoteAgents.values()) {
935
895
  if (entry.sourcePeerIp === ps.ip) {
936
896
  entry.reachable = false;
@@ -973,14 +933,14 @@ var init_peer_discovery = __esm({
973
933
  // ------------------------------------------------------------------
974
934
  // Cleanup
975
935
  // ------------------------------------------------------------------
976
- evictDownAgents() {
977
- const downPeers = /* @__PURE__ */ new Set();
936
+ evictDeadAgents() {
937
+ const deadPeers = /* @__PURE__ */ new Set();
978
938
  for (const [ip, ps] of this._peers) {
979
- if (ps.status === DOWN) downPeers.add(ip);
939
+ if (ps.status === DEAD) deadPeers.add(ip);
980
940
  }
981
- if (!downPeers.size) return;
941
+ if (!deadPeers.size) return;
982
942
  for (const [aid, entry] of this._remoteAgents) {
983
- if (downPeers.has(entry.sourcePeerIp)) {
943
+ if (deadPeers.has(entry.sourcePeerIp)) {
984
944
  this._remoteAgents.delete(aid);
985
945
  }
986
946
  }
@@ -1003,8 +963,10 @@ var AgentRegistry;
1003
963
  var init_registry = __esm({
1004
964
  "src/server/registry.ts"() {
1005
965
  "use strict";
966
+ init_config();
1006
967
  AgentRegistry = class {
1007
968
  _agents = /* @__PURE__ */ new Map();
969
+ _staleCounts = /* @__PURE__ */ new Map();
1008
970
  _selfIp;
1009
971
  constructor(selfIp) {
1010
972
  this._selfIp = selfIp;
@@ -1017,19 +979,23 @@ var init_registry = __esm({
1017
979
  host_ip: this._selfIp,
1018
980
  registered_at: now,
1019
981
  last_heartbeat: now,
1020
- metadata: req.metadata ?? {}
982
+ metadata: req.metadata ?? {},
983
+ callback_url: req.callback_url
1021
984
  };
1022
985
  this._agents.set(req.agent_id, info);
986
+ this._staleCounts.set(req.agent_id, 0);
1023
987
  return info;
1024
988
  }
1025
989
  remove(agentId) {
1026
990
  const existed = this._agents.delete(agentId);
991
+ this._staleCounts.delete(agentId);
1027
992
  return existed;
1028
993
  }
1029
994
  heartbeat(agentId) {
1030
995
  const info = this._agents.get(agentId);
1031
996
  if (!info) throw new Error(`Agent '${agentId}' not registered`);
1032
997
  info.last_heartbeat = Date.now() / 1e3;
998
+ this._staleCounts.set(agentId, 0);
1033
999
  return info;
1034
1000
  }
1035
1001
  get(agentId) {
@@ -1038,19 +1004,30 @@ var init_registry = __esm({
1038
1004
  listAll() {
1039
1005
  return Array.from(this._agents.values());
1040
1006
  }
1041
- /** Remove agents whose last heartbeat is older than timeout seconds. */
1007
+ /** Remove agents whose last heartbeat is older than timeout seconds.
1008
+ * Uses a stale counter: an agent must exceed the timeout STALE_THRESHOLD
1009
+ * consecutive times before being actually removed. Any heartbeat resets
1010
+ * the counter to zero, giving transient network issues room to recover.
1011
+ */
1042
1012
  cleanupStale(timeout) {
1043
1013
  const now = Date.now() / 1e3;
1044
- const stale = [];
1014
+ const toRemove = [];
1045
1015
  for (const [aid, info] of this._agents) {
1046
1016
  if (now - info.last_heartbeat > timeout) {
1047
- stale.push(aid);
1017
+ const count = (this._staleCounts.get(aid) ?? 0) + 1;
1018
+ this._staleCounts.set(aid, count);
1019
+ if (count >= STALE_THRESHOLD) {
1020
+ toRemove.push(aid);
1021
+ }
1022
+ } else {
1023
+ this._staleCounts.set(aid, 0);
1048
1024
  }
1049
1025
  }
1050
- for (const aid of stale) {
1026
+ for (const aid of toRemove) {
1051
1027
  this._agents.delete(aid);
1028
+ this._staleCounts.delete(aid);
1052
1029
  }
1053
- return stale;
1030
+ return toRemove;
1054
1031
  }
1055
1032
  };
1056
1033
  }
@@ -1068,7 +1045,6 @@ __export(state_exports, {
1068
1045
  messageQueue: () => messageQueue,
1069
1046
  refreshSelfIp: () => refreshSelfIp,
1070
1047
  registry: () => registry,
1071
- scheduleSnapshot: () => scheduleSnapshot,
1072
1048
  snapshotManager: () => snapshotManager
1073
1049
  });
1074
1050
  function resolveSelfIp() {
@@ -1089,12 +1065,6 @@ function refreshSelfIp() {
1089
1065
  _selfIp = resolveSelfIp();
1090
1066
  return _selfIp;
1091
1067
  }
1092
- function scheduleSnapshot() {
1093
- snapshotManager?.scheduleSnapshot(
1094
- () => registry.listAll(),
1095
- () => messageQueue.getHistorySnapshot()
1096
- );
1097
- }
1098
1068
  function initSnapshotManager(mgr) {
1099
1069
  snapshotManager = mgr;
1100
1070
  }
@@ -1119,58 +1089,6 @@ var init_state = __esm({
1119
1089
  }
1120
1090
  });
1121
1091
 
1122
- // src/cli/tailscale-installer.ts
1123
- var tailscale_installer_exports = {};
1124
- __export(tailscale_installer_exports, {
1125
- installTailscale: () => installTailscale
1126
- });
1127
- import { spawn } from "child_process";
1128
- async function installTailscale(platform) {
1129
- const entry = INSTALL_COMMANDS[platform];
1130
- if (!entry) {
1131
- return {
1132
- success: false,
1133
- output: `Unsupported platform: ${platform}. Please install manually from https://tailscale.com/download`
1134
- };
1135
- }
1136
- const cmd = entry.needsSudo ? "sudo" : entry.cmd;
1137
- const args = entry.needsSudo ? [entry.cmd, ...entry.args] : entry.args;
1138
- console.log(`Running: ${cmd} ${args.join(" ")}
1139
- `);
1140
- return new Promise((resolve) => {
1141
- const child = spawn(cmd, args, {
1142
- stdio: "inherit",
1143
- windowsHide: true
1144
- });
1145
- let exited = false;
1146
- child.on("close", (code) => {
1147
- if (exited) return;
1148
- exited = true;
1149
- if (code === 0) {
1150
- resolve({ success: true, output: "Installation completed." });
1151
- } else {
1152
- resolve({ success: false, output: `Installation exited with code ${code}` });
1153
- }
1154
- });
1155
- child.on("error", (err) => {
1156
- if (exited) return;
1157
- exited = true;
1158
- resolve({ success: false, output: err.message });
1159
- });
1160
- });
1161
- }
1162
- var INSTALL_COMMANDS;
1163
- var init_tailscale_installer = __esm({
1164
- "src/cli/tailscale-installer.ts"() {
1165
- "use strict";
1166
- INSTALL_COMMANDS = {
1167
- linux: { cmd: "bash", args: ["-c", "curl -fsSL https://tailscale.com/install.sh | sh"], needsSudo: true },
1168
- darwin: { cmd: "brew", args: ["install", "tailscale"], needsSudo: false },
1169
- win32: { cmd: "winget", args: ["install", "Tailscale.Tailscale", "--accept-source-agreements"], needsSudo: false }
1170
- };
1171
- }
1172
- });
1173
-
1174
1092
  // node_modules/hono/dist/compose.js
1175
1093
  var compose = (middleware, onError, onNotFound) => {
1176
1094
  return (context, next) => {
@@ -3991,12 +3909,34 @@ function sanitizeAgentList(agents) {
3991
3909
  // src/server/routes/agent.ts
3992
3910
  init_config();
3993
3911
  init_logger();
3912
+
3913
+ // src/server/callback.ts
3914
+ init_logger();
3915
+ var CALLBACK_TIMEOUT_MS = 5e3;
3916
+ function postCallback(callbackUrl, payload) {
3917
+ fetch(callbackUrl, {
3918
+ method: "POST",
3919
+ headers: { "Content-Type": "application/json" },
3920
+ body: JSON.stringify(payload),
3921
+ signal: AbortSignal.timeout(CALLBACK_TIMEOUT_MS)
3922
+ }).then((resp) => {
3923
+ if (!resp.ok) {
3924
+ logger.warn("Webhook", `Callback HTTP error: url=${callbackUrl}, status=${resp.status}`);
3925
+ }
3926
+ }).catch((err) => {
3927
+ logger.warn("Webhook", `Callback failed: url=${callbackUrl}, error=${err.message}`);
3928
+ });
3929
+ }
3930
+
3931
+ // src/server/routes/agent.ts
3994
3932
  var agentRoutes = new Hono2();
3995
3933
  async function forwardToPeer(peerIp, envelope) {
3996
3934
  const url = `http://${peerIp}:${PARTY_PORT}/proxy/receive`;
3997
3935
  const payload = { sender_id: envelope.sender_id, content: envelope.content };
3998
3936
  if (envelope.recipient_id) payload.recipient_id = envelope.recipient_id;
3999
3937
  if (envelope.group_id) payload.group_id = envelope.group_id;
3938
+ if (envelope.summary) payload.summary = envelope.summary;
3939
+ if (envelope.timestamp) payload.timestamp = envelope.timestamp;
4000
3940
  try {
4001
3941
  await fetch(url, {
4002
3942
  method: "POST",
@@ -4009,26 +3949,46 @@ async function forwardToPeer(peerIp, envelope) {
4009
3949
  }
4010
3950
  }
4011
3951
  agentRoutes.post("/register", async (c) => {
4012
- const { registry: registry2, scheduleSnapshot: scheduleSnapshot3 } = await Promise.resolve().then(() => (init_state(), state_exports));
3952
+ const { registry: registry2 } = await Promise.resolve().then(() => (init_state(), state_exports));
4013
3953
  const req = await c.req.json();
4014
3954
  const info = registry2.register(req);
4015
- scheduleSnapshot3();
4016
- logger.info("Agent", `Registered: ${info.agent_id} (display: "${info.display_name ?? "N/A"}")`);
3955
+ logger.info("Agent", `Registered: ${info.agent_id} (display: "${info.display_name ?? "N/A"}", callback=${info.callback_url ?? "none"})`);
4017
3956
  return c.json(sanitizeAgentInfo(info));
4018
3957
  });
4019
3958
  agentRoutes.post("/remove", async (c) => {
4020
- const { registry: registry2, scheduleSnapshot: scheduleSnapshot3 } = await Promise.resolve().then(() => (init_state(), state_exports));
3959
+ const { registry: registry2, messageQueue: messageQueue2 } = await Promise.resolve().then(() => (init_state(), state_exports));
4021
3960
  const req = await c.req.json();
4022
3961
  const removed = registry2.remove(req.agent_id);
4023
- if (removed) scheduleSnapshot3();
3962
+ if (removed) {
3963
+ messageQueue2.removeAgent(req.agent_id);
3964
+ messageQueue2.removeAgentHistory(req.agent_id);
3965
+ }
4024
3966
  logger.info("Agent", removed ? `Removed: ${req.agent_id}` : `Remove failed: ${req.agent_id} (not found)`);
4025
3967
  return c.json({ status: removed ? "removed" : "not_found" });
4026
3968
  });
4027
3969
  agentRoutes.post("/heartbeat", async (c) => {
4028
3970
  const { registry: registry2 } = await Promise.resolve().then(() => (init_state(), state_exports));
4029
3971
  const req = await c.req.json();
4030
- const info = registry2.heartbeat(req.agent_id);
4031
- logger.info("Agent", `Heartbeat from ${req.agent_id}`);
3972
+ let info;
3973
+ try {
3974
+ info = registry2.heartbeat(req.agent_id);
3975
+ if (req.callback_url) {
3976
+ info.callback_url = req.callback_url;
3977
+ }
3978
+ } catch (err) {
3979
+ if (err instanceof Error && err.message.includes("not registered")) {
3980
+ info = registry2.register({
3981
+ agent_id: req.agent_id,
3982
+ display_name: req.display_name,
3983
+ metadata: req.metadata,
3984
+ callback_url: req.callback_url
3985
+ });
3986
+ logger.info("Agent", `Auto-re-registered: ${req.agent_id} (was cleaned up)`);
3987
+ } else {
3988
+ throw err;
3989
+ }
3990
+ }
3991
+ logger.info("Agent", `Heartbeat from ${req.agent_id}${req.callback_url ? `, callback=${req.callback_url}` : ""}`);
4032
3992
  return c.json({ status: "ok", last_heartbeat: info.last_heartbeat });
4033
3993
  });
4034
3994
  agentRoutes.get("/list", async (c) => {
@@ -4039,18 +3999,31 @@ agentRoutes.get("/list", async (c) => {
4039
3999
  return c.json({ agents: sanitizeAgentList(allAgents), count: allAgents.length });
4040
4000
  });
4041
4001
  agentRoutes.post("/send", async (c) => {
4042
- const { registry: registry2, messageQueue: messageQueue2, discovery: discovery2, scheduleSnapshot: scheduleSnapshot3 } = await Promise.resolve().then(() => (init_state(), state_exports));
4002
+ const { registry: registry2, messageQueue: messageQueue2, discovery: discovery2 } = await Promise.resolve().then(() => (init_state(), state_exports));
4043
4003
  const envelope = await c.req.json();
4044
4004
  const recipient = envelope.recipient_id;
4045
4005
  if (!recipient) {
4046
4006
  return c.json({ status: "error", error: "recipient_id is required" });
4047
4007
  }
4048
- if (registry2.get(recipient)) {
4008
+ const recipientInfo = registry2.get(recipient);
4009
+ if (recipientInfo) {
4049
4010
  const stamped = { ...envelope, timestamp: envelope.timestamp ?? Date.now() / 1e3 };
4050
4011
  const count = messageQueue2.enqueue(recipient, stamped);
4051
4012
  messageQueue2.logToHistory(envelope.sender_id, "sent", stamped);
4052
- messageQueue2.logToHistory(recipient, "received", stamped);
4053
- scheduleSnapshot3();
4013
+ if (recipientInfo.callback_url) {
4014
+ const callbackPayload = {
4015
+ type: "message_received",
4016
+ recipient_id: recipient,
4017
+ sender_id: envelope.sender_id,
4018
+ summary: envelope.summary,
4019
+ timestamp: stamped.timestamp,
4020
+ pending_count: count
4021
+ };
4022
+ logger.info("Webhook", `Callback: agent=${recipient}, url=${recipientInfo.callback_url}, sender=${envelope.sender_id}`);
4023
+ postCallback(recipientInfo.callback_url, callbackPayload);
4024
+ } else {
4025
+ logger.debug("Webhook", `No callback_url for agent=${recipient}, skipping webhook`);
4026
+ }
4054
4027
  logger.info("Agent", `Send ${envelope.sender_id} -> ${recipient}: delivered_locally`);
4055
4028
  return c.json({ status: "delivered_locally", target: recipient });
4056
4029
  }
@@ -4060,7 +4033,6 @@ agentRoutes.post("/send", async (c) => {
4060
4033
  const result = await forwardToPeer(peerIp, envelope);
4061
4034
  if (result.status === "forwarded") {
4062
4035
  messageQueue2.logToHistory(envelope.sender_id, "sent", { ...envelope, timestamp: envelope.timestamp ?? Date.now() / 1e3 });
4063
- scheduleSnapshot3();
4064
4036
  logger.info("Agent", `Send ${envelope.sender_id} -> ${recipient}: forwarded (peer ${peerIp})`);
4065
4037
  }
4066
4038
  return c.json(result);
@@ -4114,9 +4086,22 @@ proxyRoutes.post("/receive", async (c) => {
4114
4086
  const envelope = await c.req.json();
4115
4087
  const stamped = { ...envelope, timestamp: envelope.timestamp ?? Date.now() / 1e3 };
4116
4088
  if (envelope.recipient_id) {
4117
- messageQueue.enqueue(envelope.recipient_id, stamped);
4089
+ const count = messageQueue.enqueue(envelope.recipient_id, stamped);
4118
4090
  messageQueue.logToHistory(envelope.recipient_id, "received", stamped);
4119
4091
  logger.info("Proxy", `Received msg ${envelope.sender_id} -> ${envelope.recipient_id}`);
4092
+ const recipientInfo = registry.get(envelope.recipient_id);
4093
+ if (recipientInfo?.callback_url) {
4094
+ const callbackPayload = {
4095
+ type: "message_received",
4096
+ recipient_id: envelope.recipient_id,
4097
+ sender_id: envelope.sender_id,
4098
+ summary: envelope.summary,
4099
+ timestamp: stamped.timestamp,
4100
+ pending_count: count
4101
+ };
4102
+ logger.info("Webhook", `Proxy callback: agent=${envelope.recipient_id}, url=${recipientInfo.callback_url}, sender=${envelope.sender_id}`);
4103
+ postCallback(recipientInfo.callback_url, callbackPayload);
4104
+ }
4120
4105
  } else {
4121
4106
  for (const agent of registry.listAll()) {
4122
4107
  messageQueue.enqueue(agent.agent_id, stamped);
@@ -4148,1224 +4133,6 @@ proxyRoutes.post("/send/:target_ip", async (c) => {
4148
4133
  }
4149
4134
  });
4150
4135
 
4151
- // src/server/dashboard-html.ts
4152
- var DASHBOARD_HTML = `<!DOCTYPE html>
4153
- <html lang="zh-CN">
4154
- <head>
4155
- <meta charset="UTF-8">
4156
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
4157
- <title>OPEN PARTY // Dashboard</title>
4158
- <style>
4159
- *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
4160
- :root{
4161
- --bg:#0a0a0f;--card:#12121a;--border:#1e1e2e;--border-bright:#2a2a3e;
4162
- --text:#e0e0e8;--muted:#6a6a8a;
4163
- --cyan:#00fff0;--magenta:#ff00ff;--green:#00ff88;--red:#ff3366;--yellow:#ffaa00;--orange:#ff8800;
4164
- --font-mono:'JetBrains Mono','Fira Code','Cascadia Code','Consolas','Courier New',monospace;
4165
- --font-sans:'Inter','Segoe UI',system-ui,-apple-system,sans-serif;
4166
- }
4167
- html{font-size:14px}
4168
- body{
4169
- background:var(--bg);color:var(--text);font-family:var(--font-sans);
4170
- min-height:100vh;overflow-x:hidden;position:relative;
4171
- }
4172
- /* Grid background */
4173
- body::before{
4174
- content:'';position:fixed;inset:0;z-index:0;pointer-events:none;
4175
- background-image:
4176
- linear-gradient(rgba(0,255,240,0.03) 1px,transparent 1px),
4177
- linear-gradient(90deg,rgba(0,255,240,0.03) 1px,transparent 1px);
4178
- background-size:40px 40px;
4179
- }
4180
- /* Scanline overlay */
4181
- body::after{
4182
- content:'';position:fixed;inset:0;z-index:9999;pointer-events:none;
4183
- background:repeating-linear-gradient(
4184
- 0deg,transparent,transparent 2px,rgba(0,0,0,0.08) 2px,rgba(0,0,0,0.08) 4px
4185
- );
4186
- }
4187
- #app{position:relative;z-index:1;max-width:1400px;margin:0 auto;padding:0 20px 40px}
4188
-
4189
- /* ===== Header ===== */
4190
- .header{
4191
- display:flex;align-items:center;justify-content:space-between;
4192
- padding:16px 0;border-bottom:1px solid var(--border);
4193
- margin-bottom:20px;flex-wrap:wrap;gap:12px;
4194
- }
4195
- .header-title{
4196
- font-family:var(--font-mono);font-size:1.4rem;font-weight:700;
4197
- color:var(--cyan);letter-spacing:3px;
4198
- text-shadow:0 0 10px rgba(0,255,240,0.5),0 0 30px rgba(0,255,240,0.2);
4199
- }
4200
- .header-title span{color:var(--muted);font-weight:400;font-size:0.9rem;margin-left:8px;letter-spacing:1px}
4201
- .header-center{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
4202
- .header-status{display:flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:0.85rem}
4203
- .status-dot{
4204
- width:8px;height:8px;border-radius:50%;background:var(--green);
4205
- box-shadow:0 0 6px var(--green);animation:pulse 2s ease-in-out infinite;
4206
- }
4207
- .status-dot.offline{background:var(--red);box-shadow:0 0 6px var(--red)}
4208
- .status-dot.not-installed{background:var(--red);box-shadow:0 0 6px var(--red)}
4209
- .status-dot.not-connected{background:var(--yellow);box-shadow:0 0 6px var(--yellow)}
4210
- @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:0.5;transform:scale(0.8)}}
4211
- .header-meta{color:var(--muted);font-family:var(--font-mono);font-size:0.8rem}
4212
- .header-right{font-family:var(--font-mono);font-size:0.85rem;color:var(--muted)}
4213
- .header-right .value{color:var(--cyan)}
4214
-
4215
- /* ===== Stats Cards ===== */
4216
- .stats-row{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}
4217
- @media(max-width:768px){.stats-row{grid-template-columns:repeat(2,1fr)}}
4218
- .stat-card{
4219
- background:var(--card);border:1px solid var(--border);border-radius:8px;
4220
- padding:16px 20px;position:relative;overflow:hidden;
4221
- transition:border-color 0.3s;
4222
- }
4223
- .stat-card:hover{border-color:var(--border-bright)}
4224
- .stat-card::before{
4225
- content:'';position:absolute;top:0;left:0;right:0;height:2px;
4226
- }
4227
- .stat-card.cyan::before{background:var(--cyan);box-shadow:0 0 10px var(--cyan)}
4228
- .stat-card.green::before{background:var(--green);box-shadow:0 0 10px var(--green)}
4229
- .stat-card.magenta::before{background:var(--magenta);box-shadow:0 0 10px var(--magenta)}
4230
- .stat-card.yellow::before{background:var(--yellow);box-shadow:0 0 10px var(--yellow)}
4231
- .stat-value{
4232
- font-family:var(--font-mono);font-size:2rem;font-weight:700;
4233
- transition:color 0.3s;
4234
- }
4235
- .stat-card.cyan .stat-value{color:var(--cyan)}
4236
- .stat-card.green .stat-value{color:var(--green)}
4237
- .stat-card.magenta .stat-value{color:var(--magenta)}
4238
- .stat-card.yellow .stat-value{color:var(--yellow)}
4239
- .stat-label{color:var(--muted);font-size:0.8rem;margin-top:4px;text-transform:uppercase;letter-spacing:1px}
4240
- .stat-value.flash{animation:flash 0.3s ease}
4241
- @keyframes flash{0%{opacity:1}50%{opacity:0.3}100%{opacity:1}}
4242
-
4243
- /* ===== Main Grid ===== */
4244
- .main-grid{display:grid;grid-template-columns:2fr 1fr;gap:20px;margin-bottom:24px}
4245
- @media(max-width:1024px){.main-grid{grid-template-columns:1fr}}
4246
- .section{
4247
- background:var(--card);border:1px solid var(--border);border-radius:8px;
4248
- overflow:hidden;
4249
- }
4250
- .section-header{
4251
- padding:12px 16px;border-bottom:1px solid var(--border);
4252
- font-family:var(--font-mono);font-size:0.8rem;font-weight:600;
4253
- color:var(--muted);letter-spacing:2px;text-transform:uppercase;
4254
- display:flex;align-items:center;gap:8px;
4255
- }
4256
- .section-header .dot{width:6px;height:6px;border-radius:50%;background:var(--cyan)}
4257
- .section-body{padding:16px}
4258
-
4259
- /* ===== Topology ===== */
4260
- .topology-container{display:flex;justify-content:center;align-items:center;min-height:300px}
4261
- .topology-container svg{width:100%;max-width:500px;height:300px}
4262
- .topo-node{cursor:pointer;transition:filter 0.3s}
4263
- .topo-node:hover{filter:brightness(1.3)}
4264
- .topo-label{font-family:var(--font-mono);font-size:10px;fill:var(--muted)}
4265
- .topo-badge{
4266
- font-family:var(--font-mono);font-size:9px;fill:var(--bg);
4267
- font-weight:700;
4268
- }
4269
-
4270
- /* ===== Peer Table ===== */
4271
- .peer-table{width:100%;border-collapse:collapse;font-size:0.85rem}
4272
- .peer-table th{
4273
- text-align:left;padding:8px 10px;color:var(--muted);font-weight:600;
4274
- font-family:var(--font-mono);font-size:0.75rem;text-transform:uppercase;
4275
- letter-spacing:1px;border-bottom:1px solid var(--border);
4276
- }
4277
- .peer-table td{padding:8px 10px;border-bottom:1px solid var(--border);font-family:var(--font-mono);font-size:0.8rem}
4278
- .peer-table tr:last-child td{border-bottom:none}
4279
- .peer-table tr:hover td{background:rgba(255,255,255,0.02)}
4280
- .badge{
4281
- display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.7rem;
4282
- font-weight:600;text-transform:uppercase;letter-spacing:0.5px;
4283
- }
4284
- .badge-party{background:rgba(0,255,136,0.15);color:var(--green)}
4285
- .badge-degraded{background:rgba(255,170,0,0.15);color:var(--yellow)}
4286
- .badge-suspect{background:rgba(255,136,0,0.15);color:var(--orange)}
4287
- .badge-down{background:rgba(255,51,102,0.15);color:var(--red)}
4288
- .badge-unknown{background:rgba(106,106,138,0.15);color:var(--muted)}
4289
- .badge-not_server{background:rgba(106,106,138,0.1);color:var(--muted)}
4290
- .badge-maybe{background:rgba(0,255,240,0.1);color:var(--cyan)}
4291
- .empty-state{text-align:center;color:var(--muted);padding:40px 20px;font-family:var(--font-mono);font-size:0.85rem}
4292
-
4293
- /* ===== Agent & Message Grid ===== */
4294
- .bottom-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px}
4295
- @media(max-width:1024px){.bottom-grid{grid-template-columns:1fr}}
4296
-
4297
- /* Agent cards */
4298
- .agent-list{display:flex;flex-direction:column;gap:8px;max-height:400px;overflow-y:auto}
4299
- .agent-card{
4300
- display:flex;align-items:center;gap:12px;padding:10px 14px;
4301
- border:1px solid var(--border);border-radius:6px;transition:border-color 0.3s;
4302
- }
4303
- .agent-card:hover{border-color:var(--border-bright)}
4304
- .agent-card.local{border-left:3px solid var(--cyan)}
4305
- .agent-card.remote{border-left:3px solid var(--green)}
4306
- .agent-icon{
4307
- width:36px;height:36px;border-radius:6px;display:flex;align-items:center;
4308
- justify-content:center;font-family:var(--font-mono);font-size:0.9rem;font-weight:700;
4309
- flex-shrink:0;
4310
- }
4311
- .agent-card.local .agent-icon{background:rgba(0,255,240,0.1);color:var(--cyan)}
4312
- .agent-card.remote .agent-icon{background:rgba(0,255,136,0.1);color:var(--green)}
4313
- .agent-info{flex:1;min-width:0}
4314
- .agent-name{font-weight:600;font-size:0.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
4315
- .agent-id{font-family:var(--font-mono);font-size:0.7rem;color:var(--muted);margin-top:2px}
4316
- .agent-meta{display:flex;gap:6px;flex-shrink:0;align-items:center}
4317
- .agent-tag{
4318
- font-family:var(--font-mono);font-size:0.65rem;padding:2px 6px;
4319
- border-radius:3px;background:rgba(255,255,255,0.05);color:var(--muted);
4320
- }
4321
- .agent-tag.unreachable{background:rgba(255,51,102,0.1);color:var(--red)}
4322
-
4323
- /* Message feed */
4324
- .msg-feed{display:flex;flex-direction:column;gap:6px;max-height:400px;overflow-y:auto}
4325
- .msg-item{
4326
- padding:10px 12px;border:1px solid var(--border);border-radius:6px;
4327
- border-left:3px solid var(--cyan);font-size:0.8rem;
4328
- }
4329
- .msg-item.received{border-left-color:var(--magenta)}
4330
- .msg-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
4331
- .msg-flow{font-family:var(--font-mono);font-size:0.75rem;color:var(--cyan)}
4332
- .msg-flow .arrow{color:var(--muted);margin:0 4px}
4333
- .msg-time{font-family:var(--font-mono);font-size:0.65rem;color:var(--muted)}
4334
- .msg-content{color:var(--text);font-size:0.8rem;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
4335
-
4336
- /* Scrollbar */
4337
- ::-webkit-scrollbar{width:4px}
4338
- ::-webkit-scrollbar-track{background:var(--bg)}
4339
- ::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
4340
- ::-webkit-scrollbar-thumb:hover{background:var(--border-bright)}
4341
-
4342
- /* Footer */
4343
- .footer{
4344
- text-align:center;padding:20px 0;border-top:1px solid var(--border);
4345
- color:var(--muted);font-family:var(--font-mono);font-size:0.75rem;
4346
- letter-spacing:1px;
4347
- }
4348
-
4349
- /* Join Network Button */
4350
- .btn-join{
4351
- font-family:var(--font-mono);font-size:0.75rem;letter-spacing:1px;
4352
- padding:6px 14px;border:1px solid var(--cyan);border-radius:4px;
4353
- background:rgba(0,255,240,0.08);color:var(--cyan);cursor:pointer;
4354
- transition:all 0.2s;text-transform:uppercase;margin-left:12px;
4355
- }
4356
- .btn-join:hover{background:rgba(0,255,240,0.18);box-shadow:0 0 10px rgba(0,255,240,0.2)}
4357
- .btn-join:active{transform:scale(0.97)}
4358
- .btn-logout{border-color:var(--red);color:var(--red);background:rgba(255,51,102,0.08)}
4359
- .btn-logout:hover{background:rgba(255,51,102,0.18);box-shadow:0 0 10px rgba(255,51,102,0.2)}
4360
- .btn-install{border-color:var(--yellow);color:var(--yellow);background:rgba(255,170,0,0.08)}
4361
- .btn-install:hover{background:rgba(255,170,0,0.18);box-shadow:0 0 10px rgba(255,170,0,0.2)}
4362
-
4363
- /* Tab bar inside modal */
4364
- .tab-bar{display:flex;gap:0;margin-bottom:18px;border-bottom:1px solid var(--border)}
4365
- .tab-bar .tab{
4366
- font-family:var(--font-mono);font-size:0.8rem;padding:8px 16px;cursor:pointer;
4367
- color:var(--muted);border-bottom:2px solid transparent;transition:all 0.2s;
4368
- }
4369
- .tab-bar .tab:hover{color:var(--text)}
4370
- .tab-bar .tab.active{color:var(--cyan);border-bottom-color:var(--cyan)}
4371
- .tab-content{display:none}
4372
- .tab-content.active{display:block}
4373
-
4374
- /* Modal */
4375
- .modal-overlay{
4376
- position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;
4377
- display:flex;align-items:center;justify-content:center;
4378
- opacity:0;pointer-events:none;transition:opacity 0.25s;
4379
- }
4380
- .modal-overlay.open{opacity:1;pointer-events:all}
4381
- .modal{
4382
- background:var(--card);border:1px solid var(--border-bright);border-radius:10px;
4383
- padding:28px 32px;width:90%;max-width:440px;
4384
- box-shadow:0 0 40px rgba(0,255,240,0.08),0 8px 32px rgba(0,0,0,0.5);
4385
- }
4386
- .modal-title{
4387
- font-family:var(--font-mono);font-size:1rem;font-weight:700;color:var(--cyan);
4388
- letter-spacing:2px;margin-bottom:6px;
4389
- text-shadow:0 0 8px rgba(0,255,240,0.3);
4390
- }
4391
- .modal-desc{color:var(--muted);font-size:0.8rem;margin-bottom:20px;line-height:1.5}
4392
- .modal-input{
4393
- width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);
4394
- border-radius:6px;color:var(--text);font-family:var(--font-mono);font-size:0.85rem;
4395
- outline:none;transition:border-color 0.2s;
4396
- }
4397
- .modal-input:focus{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,255,240,0.15)}
4398
- .modal-input::placeholder{color:var(--muted)}
4399
- .modal-actions{display:flex;gap:10px;margin-top:18px;justify-content:flex-end}
4400
- .modal-btn{
4401
- font-family:var(--font-mono);font-size:0.8rem;padding:8px 18px;
4402
- border-radius:4px;cursor:pointer;transition:all 0.2s;letter-spacing:0.5px;
4403
- }
4404
- .modal-btn-cancel{
4405
- border:1px solid var(--border);background:transparent;color:var(--muted);
4406
- }
4407
- .modal-btn-cancel:hover{border-color:var(--muted);color:var(--text)}
4408
- .modal-btn-submit{
4409
- border:1px solid var(--cyan);background:rgba(0,255,240,0.12);color:var(--cyan);
4410
- }
4411
- .modal-btn-submit:hover{background:rgba(0,255,240,0.22);box-shadow:0 0 10px rgba(0,255,240,0.2)}
4412
- .modal-btn-submit:disabled{opacity:0.4;cursor:not-allowed}
4413
- .modal-status{
4414
- margin-top:12px;padding:8px 12px;border-radius:4px;font-size:0.78rem;
4415
- font-family:var(--font-mono);display:none;
4416
- }
4417
- .modal-status.success{display:block;background:rgba(0,255,136,0.1);border:1px solid rgba(0,255,136,0.3);color:var(--green)}
4418
- .modal-status.error{display:block;background:rgba(255,51,102,0.1);border:1px solid rgba(255,51,102,0.3);color:var(--red)}
4419
- .spinner{
4420
- display:inline-block;width:12px;height:12px;border:2px solid transparent;
4421
- border-top-color:var(--cyan);border-radius:50%;animation:spin 0.6s linear infinite;
4422
- vertical-align:middle;margin-right:6px;
4423
- }
4424
- @keyframes spin{to{transform:rotate(360deg)}}
4425
-
4426
- /* ===== Tailscale Status Panel ===== */
4427
- .ts-panel{
4428
- margin-top:12px;padding:16px 20px;background:var(--card);border:1px solid var(--border);
4429
- border-radius:8px;font-family:var(--font-mono);font-size:0.8rem;
4430
- }
4431
- .ts-panel-title{
4432
- font-weight:700;letter-spacing:2px;text-transform:uppercase;margin-bottom:10px;
4433
- display:flex;align-items:center;gap:8px;
4434
- }
4435
- .ts-panel-title.connected{color:var(--green)}
4436
- .ts-panel-title.not-installed{color:var(--red)}
4437
- .ts-panel-title.not-connected{color:var(--yellow)}
4438
- .ts-info-row{display:flex;gap:8px;margin:4px 0;color:var(--muted)}
4439
- .ts-info-row .label{min-width:100px;color:var(--muted)}
4440
- .ts-info-row .value{color:var(--text)}
4441
- .ts-install-guide{
4442
- margin-top:12px;padding:12px 16px;background:rgba(0,0,0,0.3);border:1px solid var(--border);
4443
- border-radius:6px;
4444
- }
4445
- .ts-install-guide .cmd{
4446
- display:flex;align-items:center;justify-content:space-between;
4447
- padding:6px 10px;background:rgba(255,255,255,0.04);border-radius:4px;
4448
- margin:6px 0;font-size:0.8rem;cursor:pointer;transition:background 0.2s;
4449
- }
4450
- .ts-install-guide .cmd:hover{background:rgba(255,255,255,0.08)}
4451
- .ts-install-guide .cmd code{color:var(--cyan)}
4452
- .ts-install-guide .copy-hint{color:var(--muted);font-size:0.7rem}
4453
- .ts-install-guide a{color:var(--cyan);text-decoration:none}
4454
- .ts-install-guide a:hover{text-decoration:underline}
4455
- .ts-setup-hint{
4456
- margin-top:10px;padding:8px 12px;background:rgba(0,255,240,0.05);border:1px solid rgba(0,255,240,0.15);
4457
- border-radius:4px;color:var(--cyan);font-size:0.78rem;
4458
- }
4459
- .btn-redetect{
4460
- font-family:var(--font-mono);font-size:0.7rem;padding:4px 12px;
4461
- border:1px solid var(--border-bright);border-radius:4px;background:transparent;
4462
- color:var(--muted);cursor:pointer;transition:all 0.2s;margin-top:8px;
4463
- }
4464
- .btn-redetect:hover{border-color:var(--cyan);color:var(--cyan)}
4465
- </style>
4466
- </head>
4467
- <body>
4468
- <div id="app">
4469
- <header class="header">
4470
- <div class="header-title">OPEN PARTY<span>// Dashboard</span></div>
4471
- <div class="header-center">
4472
- <div class="header-status">
4473
- <div class="status-dot" id="statusDot"></div>
4474
- <span id="statusText">CONNECTING</span>
4475
- </div>
4476
- <div class="header-meta" id="serverInfo">--</div>
4477
- </div>
4478
- <div class="header-right">
4479
- UPTIME <span class="value" id="uptime">--:--:--</span>
4480
- <button class="btn-join" id="btnJoinNetwork" title="Join Tailscale Network">Join Network</button>
4481
- </div>
4482
- </header>
4483
-
4484
- <!-- Tailscale status panel (shown when not connected) -->
4485
- <div id="tsPanel" class="ts-panel" style="display:none"></div>
4486
-
4487
- <div class="stats-row" id="statsRow">
4488
- <div class="stat-card cyan">
4489
- <div class="stat-value" id="localAgentCount">-</div>
4490
- <div class="stat-label">Local Agents</div>
4491
- </div>
4492
- <div class="stat-card green">
4493
- <div class="stat-value" id="remoteAgentCount">-</div>
4494
- <div class="stat-label">Remote Agents</div>
4495
- </div>
4496
- <div class="stat-card magenta">
4497
- <div class="stat-value" id="peerCount">-</div>
4498
- <div class="stat-label">Known Peers</div>
4499
- </div>
4500
- <div class="stat-card yellow">
4501
- <div class="stat-value" id="partyServerCount">-</div>
4502
- <div class="stat-label">Party Servers</div>
4503
- </div>
4504
- </div>
4505
-
4506
- <div class="main-grid">
4507
- <div class="section">
4508
- <div class="section-header"><div class="dot"></div>NETWORK TOPOLOGY</div>
4509
- <div class="section-body">
4510
- <div class="topology-container" id="topologyContainer">
4511
- <svg id="topologySvg" viewBox="0 0 500 300"></svg>
4512
- </div>
4513
- </div>
4514
- </div>
4515
- <div class="section">
4516
- <div class="section-header"><div class="dot" style="background:var(--green)"></div>PEER HEALTH</div>
4517
- <div class="section-body" style="padding:0">
4518
- <div id="peerTableContainer">
4519
- <table class="peer-table">
4520
- <thead><tr><th>IP</th><th>Status</th><th>Fails</th><th>Last Probe</th></tr></thead>
4521
- <tbody id="peerTableBody"></tbody>
4522
- </table>
4523
- </div>
4524
- </div>
4525
- </div>
4526
- </div>
4527
-
4528
- <div class="bottom-grid">
4529
- <div class="section">
4530
- <div class="section-header"><div class="dot" style="background:var(--cyan)"></div>AGENT DIRECTORY</div>
4531
- <div class="section-body">
4532
- <div class="agent-list" id="agentList"></div>
4533
- </div>
4534
- </div>
4535
- <div class="section">
4536
- <div class="section-header"><div class="dot" style="background:var(--magenta)"></div>MESSAGE FLOW</div>
4537
- <div class="section-body">
4538
- <div class="msg-feed" id="msgFeed"></div>
4539
- </div>
4540
- </div>
4541
- </div>
4542
-
4543
- <div class="footer">OPEN PARTY v0.1 // DECENTRALIZED AGENT NETWORK</div>
4544
-
4545
- <!-- Join Network / Login Modal (two tabs: Interactive + Auth Key) -->
4546
- <div class="modal-overlay" id="joinModal">
4547
- <div class="modal">
4548
- <div class="modal-title">CONNECT TO TAILNET</div>
4549
- <div class="tab-bar" id="joinTabs">
4550
- <div class="tab active" data-tab="interactive">Interactive</div>
4551
- <div class="tab" data-tab="authkey">Auth Key</div>
4552
- </div>
4553
-
4554
- <!-- Interactive tab -->
4555
- <div class="tab-content active" id="tabInteractive">
4556
- <div class="modal-desc">Click the button below to open a browser authentication page.<br>Your Tailscale connection will be established once you authenticate.</div>
4557
- <div class="modal-status" id="interactiveStatus"></div>
4558
- <div class="modal-actions">
4559
- <button class="modal-btn modal-btn-cancel" id="btnCancelJoin">Cancel</button>
4560
- <button class="modal-btn modal-btn-submit" id="btnInteractiveLogin">Open Browser Login</button>
4561
- </div>
4562
- </div>
4563
-
4564
- <!-- Auth Key tab -->
4565
- <div class="tab-content" id="tabAuthkey">
4566
- <div class="modal-desc">Enter your Tailscale auth key to join the network.<br>You can generate one from the Tailscale admin console.</div>
4567
- <input type="password" class="modal-input" id="authKeyInput" placeholder="tskey-auth-xxxxx..." autocomplete="off" spellcheck="false" />
4568
- <div class="modal-status" id="joinStatus"></div>
4569
- <div class="modal-actions">
4570
- <button class="modal-btn modal-btn-cancel" id="btnCancelAuthkey">Cancel</button>
4571
- <button class="modal-btn modal-btn-submit" id="btnSubmitJoin">Connect</button>
4572
- </div>
4573
- </div>
4574
- </div>
4575
- </div>
4576
-
4577
- <!-- Logout Confirmation Modal -->
4578
- <div class="modal-overlay" id="logoutModal">
4579
- <div class="modal">
4580
- <div class="modal-title" style="color:var(--red)">LOG OUT OF TAILNET</div>
4581
- <div class="modal-desc">This will disconnect from Tailscale and remove your credentials.<br>You will need to re-authenticate to reconnect.</div>
4582
- <div class="modal-status" id="logoutStatus"></div>
4583
- <div class="modal-actions">
4584
- <button class="modal-btn modal-btn-cancel" id="btnCancelLogout">Cancel</button>
4585
- <button class="modal-btn modal-btn-submit" style="border-color:var(--red);color:var(--red);background:rgba(255,51,102,0.12)" id="btnConfirmLogout">Log Out</button>
4586
- </div>
4587
- </div>
4588
- </div>
4589
- </div>
4590
-
4591
- <script>
4592
- (function() {
4593
- 'use strict';
4594
-
4595
- // ---- Helpers ----
4596
- const $ = (s) => document.querySelector(s);
4597
- const $$ = (s) => document.querySelectorAll(s);
4598
-
4599
- function formatUptime(seconds) {
4600
- const h = Math.floor(seconds / 3600);
4601
- const m = Math.floor((seconds % 3600) / 60);
4602
- const s = seconds % 60;
4603
- return String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
4604
- }
4605
-
4606
- function timeAgo(ts) {
4607
- if (!ts) return '--';
4608
- const diff = Math.floor(Date.now() / 1000 - ts);
4609
- if (diff < 0) return 'now';
4610
- if (diff < 60) return diff + 's ago';
4611
- if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
4612
- return Math.floor(diff / 3600) + 'h ago';
4613
- }
4614
-
4615
- function flashEl(el) {
4616
- el.classList.remove('flash');
4617
- void el.offsetWidth;
4618
- el.classList.add('flash');
4619
- }
4620
-
4621
- const statusColors = {
4622
- PARTY_SERVER: '#00ff88',
4623
- DEGRADED: '#ffaa00',
4624
- SUSPECT: '#ff8800',
4625
- DOWN: '#ff3366',
4626
- UNKNOWN: '#6a6a8a',
4627
- NOT_SERVER: '#6a6a8a',
4628
- MAYBE: '#00fff0',
4629
- };
4630
-
4631
- const statusBadgeClass = {
4632
- PARTY_SERVER: 'badge-party',
4633
- DEGRADED: 'badge-degraded',
4634
- SUSPECT: 'badge-suspect',
4635
- DOWN: 'badge-down',
4636
- UNKNOWN: 'badge-unknown',
4637
- NOT_SERVER: 'badge-not_server',
4638
- MAYBE: 'badge-maybe',
4639
- };
4640
-
4641
- // ---- State ----
4642
- let overview = null;
4643
- let prevStats = null;
4644
-
4645
- // ---- Fetch helpers ----
4646
- async function fetchStats() {
4647
- try {
4648
- const r = await fetch('/dashboard/api/stats');
4649
- return await r.json();
4650
- } catch { return null; }
4651
- }
4652
-
4653
- async function fetchOverview() {
4654
- try {
4655
- const r = await fetch('/dashboard/api/overview');
4656
- return await r.json();
4657
- } catch { return null; }
4658
- }
4659
-
4660
- // ---- Render functions ----
4661
- function renderHeader(data) {
4662
- const s = data.server;
4663
- if (tsState && tsState.state === 'connected') {
4664
- $('#statusDot').className = 'status-dot';
4665
- $('#statusText').textContent = 'ONLINE';
4666
- $('#serverInfo').textContent = s.tailscale_ip + ' // ' + s.hostname;
4667
- } else if (tsState && tsState.state === 'not_connected') {
4668
- $('#statusDot').className = 'status-dot not-connected';
4669
- $('#statusText').textContent = 'NOT CONNECTED';
4670
- $('#serverInfo').textContent = 'Tailscale installed but not authenticated';
4671
- } else if (tsState && tsState.state === 'not_installed') {
4672
- $('#statusDot').className = 'status-dot not-installed';
4673
- $('#statusText').textContent = 'NO TAILSCALE';
4674
- $('#serverInfo').textContent = 'Tailscale not installed - local mode';
4675
- } else {
4676
- $('#statusDot').className = 'status-dot';
4677
- $('#statusText').textContent = 'ONLINE';
4678
- $('#serverInfo').textContent = s.tailscale_ip + ' // ' + s.hostname;
4679
- }
4680
- $('#uptime').textContent = formatUptime(s.uptime_seconds);
4681
- }
4682
-
4683
- function renderStats(data) {
4684
- const prev = {
4685
- local: parseInt($('#localAgentCount').textContent) || 0,
4686
- remote: parseInt($('#remoteAgentCount').textContent) || 0,
4687
- peer: parseInt($('#peerCount').textContent) || 0,
4688
- party: parseInt($('#partyServerCount').textContent) || 0,
4689
- };
4690
- const vals = {
4691
- local: data.agents.local_count,
4692
- remote: data.agents.remote_count,
4693
- peer: data.peers.total,
4694
- party: data.peers.party_servers,
4695
- };
4696
- if (vals.local !== prev.local) { $('#localAgentCount').textContent = vals.local; flashEl($('#localAgentCount')); }
4697
- if (vals.remote !== prev.remote) { $('#remoteAgentCount').textContent = vals.remote; flashEl($('#remoteAgentCount')); }
4698
- if (vals.peer !== prev.peer) { $('#peerCount').textContent = vals.peer; flashEl($('#peerCount')); }
4699
- if (vals.party !== prev.party) { $('#partyServerCount').textContent = vals.party; flashEl($('#partyServerCount')); }
4700
- }
4701
-
4702
- function renderTopology(data) {
4703
- const svg = $('#topologySvg');
4704
- const peers = data.peers.details || [];
4705
- const cx = 250, cy = 150, radius = 100;
4706
-
4707
- let html = '';
4708
-
4709
- // Center node
4710
- html += '<circle cx="' + cx + '" cy="' + cy + '" r="24" fill="rgba(0,255,240,0.15)" stroke="#00fff0" stroke-width="2">';
4711
- html += '<animate attributeName="r" values="24;26;24" dur="3s" repeatCount="indefinite"/>';
4712
- html += '</circle>';
4713
- html += '<text x="' + cx + '" y="' + cy + '" text-anchor="middle" dominant-baseline="central" fill="#00fff0" font-family="var(--font-mono)" font-size="10" font-weight="700">SELF</text>';
4714
- html += '<text x="' + cx + '" y="' + (cy + 38) + '" text-anchor="middle" class="topo-label">' + data.server.tailscale_ip + '</text>';
4715
-
4716
- if (peers.length === 0) {
4717
- html += '<text x="' + cx + '" y="' + (cy + 60) + '" text-anchor="middle" fill="#6a6a8a" font-family="var(--font-mono)" font-size="11">No peers discovered</text>';
4718
- }
4719
-
4720
- peers.forEach(function(p, i) {
4721
- const angle = (2 * Math.PI * i / Math.max(peers.length, 1)) - Math.PI / 2;
4722
- const px = cx + radius * Math.cos(angle);
4723
- const py = cy + radius * Math.sin(angle);
4724
- const color = statusColors[p.status] || '#6a6a8a';
4725
- const opacity = (p.status === 'PARTY_SERVER' || p.status === 'DEGRADED' || p.status === 'SUSPECT') ? 1 : 0.4;
4726
-
4727
- // Connection line
4728
- html += '<line x1="' + cx + '" y1="' + cy + '" x2="' + px + '" y2="' + py + '" stroke="' + color + '" stroke-width="1" opacity="' + (opacity * 0.3) + '"/>';
4729
-
4730
- // Peer node
4731
- html += '<g class="topo-node"><circle cx="' + px + '" cy="' + py + '" r="16" fill="rgba(255,255,255,0.03)" stroke="' + color + '" stroke-width="1.5" opacity="' + opacity + '"/>';
4732
- html += '<text x="' + px + '" y="' + py + '" text-anchor="middle" dominant-baseline="central" fill="' + color + '" font-family="var(--font-mono)" font-size="8" opacity="' + opacity + '">' + p.ip.split('.').slice(-1)[0] + '</text></g>';
4733
-
4734
- // IP label
4735
- html += '<text x="' + px + '" y="' + (py + 26) + '" text-anchor="middle" class="topo-label">' + p.ip + '</text>';
4736
- });
4737
-
4738
- svg.innerHTML = html;
4739
- }
4740
-
4741
- function renderPeerTable(data) {
4742
- const tbody = $('#peerTableBody');
4743
- const peers = data.peers.details || [];
4744
-
4745
- if (peers.length === 0) {
4746
- tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No peers discovered</td></tr>';
4747
- return;
4748
- }
4749
-
4750
- // Sort: PARTY_SERVER first, then by severity
4751
- const order = { PARTY_SERVER: 0, DEGRADED: 1, SUSPECT: 2, MAYBE: 3, UNKNOWN: 4, NOT_SERVER: 5, DOWN: 6 };
4752
- const sorted = [...peers].sort(function(a, b) { return (order[a.status] || 99) - (order[b.status] || 99); });
4753
-
4754
- tbody.innerHTML = sorted.map(function(p) {
4755
- const badge = statusBadgeClass[p.status] || 'badge-unknown';
4756
- const label = p.status === 'PARTY_SERVER' ? 'SERVER' : p.status === 'NOT_SERVER' ? 'NOT_SVR' : p.status;
4757
- return '<tr>'
4758
- + '<td>' + p.ip + '</td>'
4759
- + '<td><span class="badge ' + badge + '">' + label + '</span></td>'
4760
- + '<td>' + p.consecutiveFailures + '</td>'
4761
- + '<td>' + timeAgo(p.lastProbeAt) + '</td>'
4762
- + '</tr>';
4763
- }).join('');
4764
- }
4765
-
4766
- function renderAgents(data) {
4767
- const container = $('#agentList');
4768
- const local = data.agents.local_agents || [];
4769
- const remote = data.agents.remote_agents || [];
4770
- const all = [
4771
- ...local.map(function(a) { return { ...a, type: 'local' }; }),
4772
- ...remote.map(function(a) { return { ...a, type: 'remote' }; }),
4773
- ];
4774
-
4775
- if (all.length === 0) {
4776
- container.innerHTML = '<div class="empty-state">No agents registered</div>';
4777
- return;
4778
- }
4779
-
4780
- container.innerHTML = all.map(function(a) {
4781
- const initials = (a.display_name || a.agent_id).substring(0, 2).toUpperCase();
4782
- const tags = [];
4783
- if (a.type === 'remote') {
4784
- tags.push('<span class="agent-tag">' + a.source_peer_ip + '</span>');
4785
- if (!a.reachable) tags.push('<span class="agent-tag unreachable">offline</span>');
4786
- } else {
4787
- tags.push('<span class="agent-tag">local</span>');
4788
- }
4789
- return '<div class="agent-card ' + a.type + '">'
4790
- + '<div class="agent-icon">' + initials + '</div>'
4791
- + '<div class="agent-info">'
4792
- + '<div class="agent-name">' + (a.display_name || a.agent_id) + '</div>'
4793
- + '<div class="agent-id">' + a.agent_id + '</div>'
4794
- + '</div>'
4795
- + '<div class="agent-meta">' + tags.join('') + '</div>'
4796
- + '</div>';
4797
- }).join('');
4798
- }
4799
-
4800
- // Build agent_id \u2192 display_name lookup from local + remote agents
4801
- function buildNameMap(data) {
4802
- const map = {};
4803
- const all = [...(data.agents.local_agents || []), ...(data.agents.remote_agents || [])];
4804
- for (const a of all) { map[a.agent_id] = a.display_name || a.agent_id; }
4805
- return map;
4806
- }
4807
-
4808
- function resolveName(map, id) { return map[id] || id; }
4809
-
4810
- function renderMessages(data) {
4811
- const container = $('#msgFeed');
4812
- const msgs = data.messages.recent || [];
4813
-
4814
- if (msgs.length === 0) {
4815
- container.innerHTML = '<div class="empty-state">No recent messages</div>';
4816
- return;
4817
- }
4818
-
4819
- const names = buildNameMap(data);
4820
-
4821
- container.innerHTML = msgs.map(function(m) {
4822
- const dir = m.direction === 'received' ? 'received' : '';
4823
- const arrow = m.direction === 'received' ? '&#8592;' : '&#8594;';
4824
- const flow = m.direction === 'received'
4825
- ? resolveName(names, m.sender_id) + ' <span class="arrow">' + arrow + '</span> ' + resolveName(names, m.agent_id)
4826
- : resolveName(names, m.agent_id) + ' <span class="arrow">' + arrow + '</span> ' + resolveName(names, m.recipient_id) || 'broadcast';
4827
- return '<div class="msg-item ' + dir + '">'
4828
- + '<div class="msg-top">'
4829
- + '<div class="msg-flow">' + flow + '</div>'
4830
- + '<div class="msg-time">' + timeAgo(m.timestamp) + '</div>'
4831
- + '</div>'
4832
- + '<div class="msg-content">' + (m.summary || m.content) + '</div>'
4833
- + '</div>';
4834
- }).join('');
4835
- }
4836
-
4837
- function renderAll(data) {
4838
- renderHeader(data);
4839
- renderStats(data);
4840
- renderTopology(data);
4841
- renderPeerTable(data);
4842
- renderAgents(data);
4843
- renderMessages(data);
4844
- }
4845
-
4846
- // ---- Polling ----
4847
- let fullTimer = null;
4848
- let fastTimer = null;
4849
-
4850
- async function fullRefresh() {
4851
- const data = await fetchOverview();
4852
- if (!data) {
4853
- $('#statusDot').className = 'status-dot offline';
4854
- $('#statusText').textContent = 'OFFLINE';
4855
- return;
4856
- }
4857
- overview = data;
4858
- renderAll(data);
4859
- }
4860
-
4861
- async function fastRefresh() {
4862
- const stats = await fetchStats();
4863
- if (!stats) return;
4864
- const changed = JSON.stringify(stats) !== JSON.stringify(prevStats);
4865
- prevStats = stats;
4866
- if (changed) {
4867
- fullRefresh();
4868
- }
4869
- }
4870
-
4871
- // ---- Init ----
4872
- fullRefresh();
4873
- fastTimer = setInterval(fastRefresh, 3000);
4874
- fullTimer = setInterval(fullRefresh, 5000);
4875
-
4876
- // Clipboard click delegation for .cmd elements
4877
- document.addEventListener('click', function(e) {
4878
- var cmd = e.target.closest('.cmd');
4879
- if (!cmd) return;
4880
- var text = cmd.getAttribute('data-clipboard');
4881
- if (text) {
4882
- navigator.clipboard.writeText(text).then(function() {
4883
- var hint = cmd.querySelector('.copy-hint');
4884
- if (hint) hint.textContent = 'Copied!';
4885
- });
4886
- }
4887
- });
4888
-
4889
- // Update uptime display every second
4890
- setInterval(function() {
4891
- if (overview && overview.server) {
4892
- overview.server.uptime_seconds++;
4893
- $('#uptime').textContent = formatUptime(overview.server.uptime_seconds);
4894
- }
4895
- }, 1000);
4896
-
4897
- // ---- Join Modal Tabs ----
4898
- const joinTabs = $$('#joinTabs .tab');
4899
- joinTabs.forEach(function(tab) {
4900
- tab.addEventListener('click', function() {
4901
- joinTabs.forEach(function(t) { t.classList.remove('active'); });
4902
- tab.classList.add('active');
4903
- // Toggle tab contents
4904
- const target = tab.getAttribute('data-tab');
4905
- $$('.tab-content').forEach(function(tc) { tc.classList.remove('active'); });
4906
- if (target === 'interactive') {
4907
- $('#tabInteractive').classList.add('active');
4908
- } else {
4909
- $('#tabAuthkey').classList.add('active');
4910
- }
4911
- });
4912
- });
4913
-
4914
- // ---- Join Network Modal (open/close) ----
4915
- const joinModal = $('#joinModal');
4916
- const btnJoin = $('#btnJoinNetwork');
4917
- const btnCancel = $('#btnCancelJoin');
4918
- const btnCancelAuthkey = $('#btnCancelAuthkey');
4919
- const btnSubmit = $('#btnSubmitJoin');
4920
- const authKeyInput = $('#authKeyInput');
4921
- const joinStatus = $('#joinStatus');
4922
-
4923
- function openJoinModal() {
4924
- // Reset both tabs
4925
- joinStatus.className = 'modal-status';
4926
- joinStatus.textContent = '';
4927
- authKeyInput.value = '';
4928
- $('#interactiveStatus').className = 'modal-status';
4929
- $('#interactiveStatus').textContent = '';
4930
- // Default to Interactive tab
4931
- $$('#joinTabs .tab').forEach(function(t) { t.classList.remove('active'); });
4932
- $$('#joinTabs .tab')[0].classList.add('active');
4933
- $$('.tab-content').forEach(function(tc) { tc.classList.remove('active'); });
4934
- $('#tabInteractive').classList.add('active');
4935
- joinModal.classList.add('open');
4936
- }
4937
-
4938
- function closeJoinModal() {
4939
- joinModal.classList.remove('open');
4940
- }
4941
-
4942
- btnJoin.addEventListener('click', function() {
4943
- // Decide action based on Tailscale state
4944
- if (tsState && tsState.state === 'connected') {
4945
- openLogoutModal();
4946
- } else if (tsState && tsState.state === 'not_installed') {
4947
- doInstallTailscale();
4948
- } else {
4949
- openJoinModal();
4950
- }
4951
- });
4952
- btnCancel.addEventListener('click', closeJoinModal);
4953
- btnCancelAuthkey.addEventListener('click', closeJoinModal);
4954
- joinModal.addEventListener('click', function(e) {
4955
- if (e.target === joinModal) closeJoinModal();
4956
- });
4957
- authKeyInput.addEventListener('keydown', function(e) {
4958
- if (e.key === 'Enter') btnSubmit.click();
4959
- if (e.key === 'Escape') closeJoinModal();
4960
- });
4961
-
4962
- // ---- Auth Key submit ----
4963
- btnSubmit.addEventListener('click', async function() {
4964
- const key = authKeyInput.value.trim();
4965
- if (!key) {
4966
- authKeyInput.focus();
4967
- return;
4968
- }
4969
- btnSubmit.disabled = true;
4970
- btnSubmit.innerHTML = '<span class="spinner"></span>Connecting...';
4971
- joinStatus.className = 'modal-status';
4972
- joinStatus.textContent = '';
4973
-
4974
- try {
4975
- const r = await fetch('/dashboard/api/join-network', {
4976
- method: 'POST',
4977
- headers: { 'Content-Type': 'application/json' },
4978
- body: JSON.stringify({ auth_key: key }),
4979
- });
4980
- const data = await r.json();
4981
- if (data.success) {
4982
- joinStatus.className = 'modal-status success';
4983
- joinStatus.textContent = 'Successfully joined network!';
4984
- btnJoin.textContent = 'Logout';
4985
- btnJoin.className = 'btn-join btn-logout';
4986
- setTimeout(function() { closeJoinModal(); checkTailscaleStatus(); fullRefresh(); }, 1500);
4987
- } else {
4988
- joinStatus.className = 'modal-status error';
4989
- joinStatus.textContent = data.output || 'Failed to join network';
4990
- }
4991
- } catch (e) {
4992
- joinStatus.className = 'modal-status error';
4993
- joinStatus.textContent = 'Network error: ' + (e.message || 'unknown');
4994
- }
4995
- btnSubmit.disabled = false;
4996
- btnSubmit.textContent = 'Connect';
4997
- });
4998
-
4999
- // ---- Interactive Login ----
5000
- const btnInteractiveLogin = $('#btnInteractiveLogin');
5001
- btnInteractiveLogin.addEventListener('click', async function() {
5002
- const statusEl = $('#interactiveStatus');
5003
- statusEl.className = 'modal-status';
5004
- statusEl.textContent = '';
5005
- btnInteractiveLogin.disabled = true;
5006
- btnInteractiveLogin.innerHTML = '<span class="spinner"></span>Opening browser...';
5007
-
5008
- try {
5009
- const r = await fetch('/dashboard/api/tailscale-login', { method: 'POST' });
5010
- const data = await r.json();
5011
-
5012
- if (data.success && data.url) {
5013
- // Open the auth URL in a new tab
5014
- window.open(data.url, '_blank');
5015
- statusEl.className = 'modal-status success';
5016
- statusEl.textContent = 'Authentication page opened in your browser. Waiting for connection...';
5017
-
5018
- // Poll for connection
5019
- var pollCount = 0;
5020
- var pollInterval = setInterval(async function() {
5021
- pollCount++;
5022
- if (pollCount > 40) { // 2 minutes timeout
5023
- clearInterval(pollInterval);
5024
- statusEl.className = 'modal-status error';
5025
- statusEl.textContent = 'Timed out waiting for authentication. Please try again.';
5026
- btnInteractiveLogin.disabled = false;
5027
- btnInteractiveLogin.textContent = 'Open Browser Login';
5028
- return;
5029
- }
5030
- try {
5031
- var sr = await fetch('/dashboard/api/tailscale-status');
5032
- var sd = await sr.json();
5033
- if (sd.state === 'connected') {
5034
- clearInterval(pollInterval);
5035
- btnJoin.textContent = 'Logout';
5036
- btnJoin.className = 'btn-join btn-logout';
5037
- closeJoinModal();
5038
- checkTailscaleStatus();
5039
- fullRefresh();
5040
- return;
5041
- }
5042
- } catch { /* poll error, continue */ }
5043
- }, 3000);
5044
- } else {
5045
- statusEl.className = 'modal-status error';
5046
- statusEl.textContent = data.output || 'Failed to start interactive login';
5047
- btnInteractiveLogin.disabled = false;
5048
- btnInteractiveLogin.textContent = 'Open Browser Login';
5049
- }
5050
- } catch (e) {
5051
- statusEl.className = 'modal-status error';
5052
- statusEl.textContent = 'Network error: ' + (e.message || 'unknown');
5053
- btnInteractiveLogin.disabled = false;
5054
- btnInteractiveLogin.textContent = 'Open Browser Login';
5055
- }
5056
- });
5057
-
5058
- // ---- Logout Modal ----
5059
- const logoutModal = $('#logoutModal');
5060
- const btnConfirmLogout = $('#btnConfirmLogout');
5061
- const btnCancelLogout = $('#btnCancelLogout');
5062
- const logoutStatus = $('#logoutStatus');
5063
-
5064
- function openLogoutModal() {
5065
- logoutStatus.className = 'modal-status';
5066
- logoutStatus.textContent = '';
5067
- logoutModal.classList.add('open');
5068
- }
5069
-
5070
- btnCancelLogout.addEventListener('click', function() { logoutModal.classList.remove('open'); });
5071
- logoutModal.addEventListener('click', function(e) { if (e.target === logoutModal) logoutModal.classList.remove('open'); });
5072
-
5073
- btnConfirmLogout.addEventListener('click', async function() {
5074
- btnConfirmLogout.disabled = true;
5075
- btnConfirmLogout.innerHTML = '<span class="spinner"></span>Logging out...';
5076
- logoutStatus.className = 'modal-status';
5077
- logoutStatus.textContent = '';
5078
-
5079
- try {
5080
- const r = await fetch('/dashboard/api/logout', { method: 'POST' });
5081
- const data = await r.json();
5082
- logoutModal.classList.remove('open');
5083
- if (data.success) {
5084
- checkTailscaleStatus();
5085
- fullRefresh();
5086
- } else {
5087
- alert('Logout failed: ' + (data.output || 'unknown error'));
5088
- }
5089
- } catch (e) {
5090
- logoutModal.classList.remove('open');
5091
- alert('Network error: ' + (e.message || 'unknown'));
5092
- }
5093
- btnConfirmLogout.disabled = false;
5094
- btnConfirmLogout.textContent = 'Log Out';
5095
- });
5096
-
5097
- // ---- Install Tailscale ----
5098
- async function doInstallTailscale() {
5099
- if (!confirm('Install Tailscale on this machine?')) return;
5100
-
5101
- btnJoin.disabled = true;
5102
- btnJoin.innerHTML = '<span class="spinner"></span>Installing...';
5103
-
5104
- try {
5105
- const r = await fetch('/dashboard/api/install-tailscale', { method: 'POST' });
5106
- const data = await r.json();
5107
- if (data.success) {
5108
- btnJoin.textContent = 'Installed';
5109
- btnJoin.disabled = false;
5110
- checkTailscaleStatus();
5111
- fullRefresh();
5112
- } else {
5113
- alert('Installation failed: ' + (data.output || 'unknown error'));
5114
- btnJoin.textContent = 'Install Tailscale';
5115
- btnJoin.className = 'btn-join btn-install';
5116
- btnJoin.disabled = false;
5117
- }
5118
- } catch (e) {
5119
- alert('Network error: ' + (e.message || 'unknown'));
5120
- btnJoin.textContent = 'Install Tailscale';
5121
- btnJoin.className = 'btn-join btn-install';
5122
- btnJoin.disabled = false;
5123
- }
5124
- }
5125
-
5126
- // Check initial Tailscale status (tri-state)
5127
- let tsState = null;
5128
- let tsInstallInfo = null;
5129
-
5130
- async function checkTailscaleStatus() {
5131
- try {
5132
- const r = await fetch('/dashboard/api/tailscale-status');
5133
- tsState = await r.json();
5134
- } catch { tsState = { state: 'not_installed', platform: 'unknown' }; }
5135
-
5136
- const dot = $('#statusDot');
5137
- const text = $('#statusText');
5138
- const btnJoin = $('#btnJoinNetwork');
5139
- const panel = $('#tsPanel');
5140
-
5141
- if (tsState.state === 'connected') {
5142
- dot.className = 'status-dot';
5143
- text.textContent = 'ONLINE';
5144
- btnJoin.textContent = 'Logout';
5145
- btnJoin.className = 'btn-join btn-logout';
5146
- btnJoin.style.display = '';
5147
- panel.style.display = 'none';
5148
- } else if (tsState.state === 'not_installed') {
5149
- dot.className = 'status-dot not-installed';
5150
- text.textContent = 'NOT INSTALLED';
5151
- btnJoin.textContent = 'Install Tailscale';
5152
- btnJoin.className = 'btn-join btn-install';
5153
- btnJoin.style.display = '';
5154
- await renderNotInstalledPanel();
5155
- } else {
5156
- dot.className = 'status-dot not-connected';
5157
- text.textContent = 'NOT CONNECTED';
5158
- btnJoin.textContent = 'Join Network';
5159
- btnJoin.className = 'btn-join';
5160
- btnJoin.style.display = '';
5161
- await renderNotConnectedPanel();
5162
- }
5163
- }
5164
-
5165
- async function fetchInstallInfo() {
5166
- if (tsInstallInfo) return tsInstallInfo;
5167
- try {
5168
- const r = await fetch('/dashboard/api/tailscale-install-info');
5169
- tsInstallInfo = await r.json();
5170
- } catch { tsInstallInfo = null; }
5171
- return tsInstallInfo;
5172
- }
5173
-
5174
- async function renderNotInstalledPanel() {
5175
- const info = await fetchInstallInfo();
5176
- const panel = $('#tsPanel');
5177
- let html = '<div class="ts-panel-title not-installed">Tailscale Not Installed</div>';
5178
- html += '<div class="ts-info-row"><span class="label">Status:</span><span class="value" style="color:var(--red)">Tailscale is not detected on this system</span></div>';
5179
-
5180
- if (info && info.commands && info.commands.length > 0) {
5181
- html += '<div class="ts-install-guide">';
5182
- html += '<div style="color:var(--muted);margin-bottom:6px">Install for ' + info.os + ':</div>';
5183
- info.commands.forEach(function(cmd) {
5184
- const display = info.needs_sudo ? 'sudo ' + cmd : cmd;
5185
- html += '<div class="cmd" data-clipboard="' + display.replace(/"/g, '&quot;') + '">';
5186
- html += '<code>' + display + '</code><span class="copy-hint">Click to copy</span></div>';
5187
- });
5188
- if (info.download_url) {
5189
- html += '<div style="margin-top:8px">Download: <a href="' + info.download_url + '" target="_blank">' + info.download_url + '</a></div>';
5190
- }
5191
- html += '</div>';
5192
- }
5193
-
5194
- html += '<div class="ts-setup-hint">Or click the <strong>Install Tailscale</strong> button above, or run <code style="color:var(--cyan)">npx open-party setup</code></div>';
5195
- html += '<button class="btn-redetect" onclick="window.__redetectTailscale()">Re-detect</button>';
5196
- panel.innerHTML = html;
5197
- panel.style.display = 'block';
5198
- }
5199
-
5200
- async function renderNotConnectedPanel() {
5201
- const panel = $('#tsPanel');
5202
-
5203
- let html = '<div class="ts-panel-title not-connected">Tailscale Not Connected</div>';
5204
- html += '<div class="ts-info-row"><span class="label">Status:</span><span class="value" style="color:var(--yellow)">Installed but not authenticated</span></div>';
5205
- html += '<div class="ts-setup-hint">Use the <strong>Join Network</strong> button above to log in</div>';
5206
- html += '<button class="btn-redetect" onclick="window.__redetectTailscale()">Re-detect</button>';
5207
- panel.innerHTML = html;
5208
- panel.style.display = 'block';
5209
- }
5210
-
5211
- window.__redetectTailscale = async function() {
5212
- const panel = $('#tsPanel');
5213
- panel.innerHTML = '<div style="color:var(--muted);padding:12px"><span class="spinner"></span> Re-detecting Tailscale...</div>';
5214
- try {
5215
- await fetch('/dashboard/api/tailscale-detect', { method: 'POST' });
5216
- } catch { /* ignore */ }
5217
- await checkTailscaleStatus();
5218
- fullRefresh();
5219
- };
5220
-
5221
- checkTailscaleStatus();
5222
-
5223
- })();
5224
- </script>
5225
- </body>
5226
- </html>`;
5227
-
5228
- // src/server/routes/dashboard.ts
5229
- init_tailscale();
5230
- init_state();
5231
- init_logger();
5232
- var dashboardRoutes = new Hono2();
5233
- dashboardRoutes.get("/", (c) => {
5234
- return c.html(DASHBOARD_HTML);
5235
- });
5236
- dashboardRoutes.get("/api/stats", async (c) => {
5237
- const localAgents = registry.listAll();
5238
- const remoteAgents = discovery.getReachableRemoteAgents();
5239
- const peerStates = discovery.getPeerStates();
5240
- const partyServers = peerStates.filter((p) => p.status === "PARTY_SERVER" || p.status === "DEGRADED" || p.status === "SUSPECT");
5241
- return c.json({
5242
- local_agent_count: localAgents.length,
5243
- remote_agent_count: remoteAgents.length,
5244
- peer_count: peerStates.length,
5245
- party_server_count: partyServers.length
5246
- });
5247
- });
5248
- dashboardRoutes.get("/api/overview", async (c) => {
5249
- let hostname = "127.0.0.1";
5250
- try {
5251
- hostname = getTailnetHostname();
5252
- } catch {
5253
- }
5254
- const localAgents = sanitizeAgentList(registry.listAll());
5255
- const remoteEntries = discovery.getRemoteAgentEntries();
5256
- const peerStates = discovery.getPeerStates();
5257
- const seen = /* @__PURE__ */ new Set();
5258
- const recentMessages = [];
5259
- for (const agent of localAgents) {
5260
- const history = messageQueue.getHistory(agent.agent_id, 5);
5261
- for (const entry of history) {
5262
- const key = `${entry.sender_id}:${entry.recipient_id ?? ""}:${Math.floor(entry.timestamp)}`;
5263
- if (seen.has(key)) continue;
5264
- seen.add(key);
5265
- recentMessages.push({ agent_id: agent.agent_id, ...entry });
5266
- }
5267
- }
5268
- recentMessages.sort((a, b) => b.timestamp - a.timestamp);
5269
- if (recentMessages.length > 20) recentMessages.length = 20;
5270
- const partyServers = peerStates.filter(
5271
- (p) => p.status === "PARTY_SERVER" || p.status === "DEGRADED" || p.status === "SUSPECT"
5272
- );
5273
- return c.json({
5274
- server: {
5275
- status: "ok",
5276
- tailscale_ip: getSelfIp(),
5277
- hostname,
5278
- uptime_seconds: Math.floor((Date.now() - STARTED_AT) / 1e3)
5279
- },
5280
- agents: {
5281
- local_count: localAgents.length,
5282
- remote_count: remoteEntries.length,
5283
- local_agents: localAgents,
5284
- remote_agents: remoteEntries.map((e) => ({
5285
- ...sanitizeAgentList([e.agentInfo])[0],
5286
- source_peer_ip: e.sourcePeerIp,
5287
- reachable: e.reachable
5288
- }))
5289
- },
5290
- peers: {
5291
- total: peerStates.length,
5292
- party_servers: partyServers.length,
5293
- down: peerStates.filter((p) => p.status === "DOWN").length,
5294
- unknown: peerStates.filter((p) => p.status === "UNKNOWN" || p.status === "MAYBE").length,
5295
- details: peerStates
5296
- },
5297
- messages: {
5298
- recent: recentMessages
5299
- }
5300
- });
5301
- });
5302
- dashboardRoutes.get("/api/tailscale-status", async (c) => {
5303
- try {
5304
- return c.json(getTailscaleInstallationStatus());
5305
- } catch (e) {
5306
- return c.json({ state: "not_installed", platform: process.platform, error: e.message });
5307
- }
5308
- });
5309
- dashboardRoutes.post("/api/tailscale-detect", async (c) => {
5310
- resetTailscaleBinaryCache();
5311
- const state = getTailscaleInstallationStatus();
5312
- if (state.state === "connected") {
5313
- refreshSelfIp();
5314
- }
5315
- return c.json(state);
5316
- });
5317
- dashboardRoutes.get("/api/tailscale-install-info", async (c) => {
5318
- return c.json(getInstallInstructions(process.platform));
5319
- });
5320
- dashboardRoutes.post("/api/join-network", async (c) => {
5321
- try {
5322
- const body = await c.req.json();
5323
- const authKey = (body.auth_key ?? "").trim();
5324
- if (!authKey) {
5325
- return c.json({ success: false, output: "auth_key is required" }, 400);
5326
- }
5327
- const result = joinTailnet(authKey);
5328
- logger.info("Dashboard", `Join network: ${result.success ? "success" : "failed"}`);
5329
- return c.json(result, result.success ? 200 : 500);
5330
- } catch (e) {
5331
- return c.json({ success: false, output: e.message }, 500);
5332
- }
5333
- });
5334
- var activeLogin = null;
5335
- dashboardRoutes.post("/api/logout", async (c) => {
5336
- const result = logoutTailscale();
5337
- logger.info("Dashboard", `Logout: ${result.success ? "success" : "failed"}`);
5338
- if (result.success) {
5339
- resetTailscaleBinaryCache();
5340
- refreshSelfIp();
5341
- }
5342
- return c.json(result, result.success ? 200 : 500);
5343
- });
5344
- dashboardRoutes.post("/api/tailscale-login", async (c) => {
5345
- if (activeLogin?.url) {
5346
- return c.json({ success: true, url: activeLogin.url });
5347
- }
5348
- const { promise, process: process2 } = startInteractiveLogin();
5349
- activeLogin = { process: process2 };
5350
- logger.info("Dashboard", "Tailscale login initiated");
5351
- const result = await promise;
5352
- if (result.success && result.url) {
5353
- activeLogin.url = result.url;
5354
- return c.json({ success: true, url: result.url });
5355
- }
5356
- activeLogin = null;
5357
- return c.json({ success: false, output: result.output }, 500);
5358
- });
5359
- dashboardRoutes.post("/api/install-tailscale", async (c) => {
5360
- const { installTailscale: installTailscale2 } = await Promise.resolve().then(() => (init_tailscale_installer(), tailscale_installer_exports));
5361
- const result = await installTailscale2(process.platform);
5362
- logger.info("Dashboard", `Install Tailscale: ${result.success ? "success" : "failed"}`);
5363
- if (result.success) {
5364
- resetTailscaleBinaryCache();
5365
- }
5366
- return c.json(result, result.success ? 200 : 500);
5367
- });
5368
-
5369
4136
  // src/server/index.ts
5370
4137
  async function periodicCleanup() {
5371
4138
  while (!lifecycleController.signal.aborted) {
@@ -5373,6 +4140,10 @@ async function periodicCleanup() {
5373
4140
  const removed = registry.cleanupStale(HEARTBEAT_TIMEOUT);
5374
4141
  if (removed.length > 0) {
5375
4142
  logger.info("Cleanup", `Removed ${removed.length} stale agent(s): ${removed.join(", ")}`);
4143
+ for (const aid of removed) {
4144
+ messageQueue.removeAgent(aid);
4145
+ messageQueue.removeAgentHistory(aid);
4146
+ }
5376
4147
  }
5377
4148
  } catch (e) {
5378
4149
  logger.error("Cleanup", "Error during cleanup", e);
@@ -5403,7 +4174,6 @@ app.onError((err, c) => {
5403
4174
  });
5404
4175
  app.route("/agent", agentRoutes);
5405
4176
  app.route("/proxy", proxyRoutes);
5406
- app.route("/dashboard", dashboardRoutes);
5407
4177
  function pidFilePath() {
5408
4178
  const pluginData = process.env.CLAUDE_PLUGIN_DATA || "";
5409
4179
  if (pluginData) return join4(pluginData, "server.pid");
@@ -5416,7 +4186,6 @@ async function performShutdown(server, pidPath) {
5416
4186
  logger.info("Shutdown", "Shutting down Party Server...");
5417
4187
  try {
5418
4188
  lifecycleController.abort();
5419
- getSnapshotManager()?.cancelDebounce();
5420
4189
  try {
5421
4190
  getSnapshotManager()?.writeSnapshot(registry.listAll(), messageQueue.getHistorySnapshot());
5422
4191
  logger.info("Shutdown", "Final snapshot written.");
@@ -5461,7 +4230,7 @@ async function main() {
5461
4230
  const savedSnapshot = sm.loadSnapshot();
5462
4231
  if (savedSnapshot) {
5463
4232
  recoveredAgents = sm.hydrateAgents(registry, getSelfIp());
5464
- recoveredHistoryEntries = sm.hydrateHistory(messageQueue);
4233
+ recoveredHistoryEntries = sm.hydrateBuffers(messageQueue);
5465
4234
  if (recoveredAgents > 0 || recoveredHistoryEntries > 0) {
5466
4235
  logger.info(
5467
4236
  "Recovery",
@@ -5478,7 +4247,7 @@ async function main() {
5478
4247
  const snapshotLoopPromise = sm.startSnapshotLoop(
5479
4248
  lifecycleController.signal,
5480
4249
  () => registry.listAll(),
5481
- () => messageQueue.getHistorySnapshot()
4250
+ () => messageQueue.getBufferSnapshots()
5482
4251
  );
5483
4252
  const shutdownHandler = () => void performShutdown(server, pidPath);
5484
4253
  process.on("SIGINT", shutdownHandler);