@agent-relay/dashboard-server 2.0.63 → 2.0.65

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 (69) hide show
  1. package/dist/messageBuffer.d.ts +38 -0
  2. package/dist/messageBuffer.d.ts.map +1 -0
  3. package/dist/messageBuffer.js +72 -0
  4. package/dist/messageBuffer.js.map +1 -0
  5. package/dist/server.d.ts.map +1 -1
  6. package/dist/server.js +149 -23
  7. package/dist/server.js.map +1 -1
  8. package/out/404.html +1 -1
  9. package/out/_next/static/chunks/535-757cbf5de3af1d18.js +1 -0
  10. package/out/_next/static/chunks/app/app/[[...slug]]/{page-a528040db9d1fec0.js → page-7c9abc28789ea7cb.js} +1 -1
  11. package/out/_next/static/chunks/app/{page-a32b25323fff7aa0.js → page-ba281b017e148cd6.js} +1 -1
  12. package/out/_next/static/css/15362c88976df1b9.css +1 -0
  13. package/out/about.html +2 -2
  14. package/out/about.txt +1 -1
  15. package/out/app/onboarding.html +1 -1
  16. package/out/app/onboarding.txt +1 -1
  17. package/out/app.html +1 -1
  18. package/out/app.txt +2 -2
  19. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
  20. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  21. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  22. package/out/blog/let-them-cook-multi-agent-orchestration.txt +1 -1
  23. package/out/blog.html +2 -2
  24. package/out/blog.txt +1 -1
  25. package/out/careers.html +2 -2
  26. package/out/careers.txt +1 -1
  27. package/out/changelog.html +2 -2
  28. package/out/changelog.txt +1 -1
  29. package/out/cloud/link.html +1 -1
  30. package/out/cloud/link.txt +1 -1
  31. package/out/complete-profile.html +2 -2
  32. package/out/complete-profile.txt +1 -1
  33. package/out/connect-repos.html +1 -1
  34. package/out/connect-repos.txt +1 -1
  35. package/out/contact.html +2 -2
  36. package/out/contact.txt +1 -1
  37. package/out/docs.html +2 -2
  38. package/out/docs.txt +1 -1
  39. package/out/history.html +1 -1
  40. package/out/history.txt +1 -1
  41. package/out/index.html +1 -1
  42. package/out/index.txt +2 -2
  43. package/out/login.html +2 -2
  44. package/out/login.txt +1 -1
  45. package/out/metrics.html +1 -1
  46. package/out/metrics.txt +1 -1
  47. package/out/pricing.html +2 -2
  48. package/out/pricing.txt +1 -1
  49. package/out/privacy.html +2 -2
  50. package/out/privacy.txt +1 -1
  51. package/out/providers/setup/claude.html +1 -1
  52. package/out/providers/setup/claude.txt +1 -1
  53. package/out/providers/setup/codex.html +1 -1
  54. package/out/providers/setup/codex.txt +1 -1
  55. package/out/providers/setup/cursor.html +1 -1
  56. package/out/providers/setup/cursor.txt +1 -1
  57. package/out/providers.html +1 -1
  58. package/out/providers.txt +1 -1
  59. package/out/security.html +2 -2
  60. package/out/security.txt +1 -1
  61. package/out/signup.html +2 -2
  62. package/out/signup.txt +1 -1
  63. package/out/terms.html +2 -2
  64. package/out/terms.txt +1 -1
  65. package/package.json +10 -10
  66. package/out/_next/static/chunks/873-6b31247a84ec58c2.js +0 -1
  67. package/out/_next/static/css/ad96af0f7a47b705.css +0 -1
  68. /package/out/_next/static/{Ip08bs-aI4i94zrABOaVi → XAoBjrJ3N72573Ty4Ja_J}/_buildManifest.js +0 -0
  69. /package/out/_next/static/{Ip08bs-aI4i94zrABOaVi → XAoBjrJ3N72573Ty4Ja_J}/_ssgManifest.js +0 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Ring buffer for storing recent WebSocket messages.
3
+ * Used to replay missed messages when clients reconnect after brief disconnects.
4
+ */
5
+ export interface BufferedMessage {
6
+ id: number;
7
+ timestamp: number;
8
+ type: string;
9
+ payload: string;
10
+ }
11
+ export declare class MessageBuffer {
12
+ private buffer;
13
+ private capacity;
14
+ private writeIndex;
15
+ private sequenceCounter;
16
+ constructor(capacity?: number);
17
+ /**
18
+ * Push a new message into the buffer.
19
+ * Returns the assigned sequence ID.
20
+ */
21
+ push(type: string, payload: string): number;
22
+ /**
23
+ * Get all messages with an ID greater than the given sequence ID.
24
+ * Returns messages in chronological order.
25
+ */
26
+ getAfter(sequenceId: number): BufferedMessage[];
27
+ /**
28
+ * Get all messages with a timestamp greater than the given timestamp.
29
+ * Returns messages in chronological order.
30
+ */
31
+ getAfterTimestamp(ts: number): BufferedMessage[];
32
+ /**
33
+ * Get the current sequence ID (the ID of the last pushed message).
34
+ * Returns 0 if no messages have been pushed.
35
+ */
36
+ currentId(): number;
37
+ }
38
+ //# sourceMappingURL=messageBuffer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"messageBuffer.d.ts","sourceRoot":"","sources":["../src/messageBuffer.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,eAAe,CAAS;gBAEpB,QAAQ,GAAE,MAAY;IAOlC;;;OAGG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM;IAa3C;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,eAAe,EAAE;IAa/C;;;OAGG;IACH,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,eAAe,EAAE;IAahD;;;OAGG;IACH,SAAS,IAAI,MAAM;CAGpB"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Ring buffer for storing recent WebSocket messages.
3
+ * Used to replay missed messages when clients reconnect after brief disconnects.
4
+ */
5
+ export class MessageBuffer {
6
+ buffer;
7
+ capacity;
8
+ writeIndex;
9
+ sequenceCounter;
10
+ constructor(capacity = 500) {
11
+ this.capacity = capacity;
12
+ this.buffer = new Array(capacity).fill(null);
13
+ this.writeIndex = 0;
14
+ this.sequenceCounter = 0;
15
+ }
16
+ /**
17
+ * Push a new message into the buffer.
18
+ * Returns the assigned sequence ID.
19
+ */
20
+ push(type, payload) {
21
+ this.sequenceCounter++;
22
+ const message = {
23
+ id: this.sequenceCounter,
24
+ timestamp: Date.now(),
25
+ type,
26
+ payload,
27
+ };
28
+ this.buffer[this.writeIndex] = message;
29
+ this.writeIndex = (this.writeIndex + 1) % this.capacity;
30
+ return this.sequenceCounter;
31
+ }
32
+ /**
33
+ * Get all messages with an ID greater than the given sequence ID.
34
+ * Returns messages in chronological order.
35
+ */
36
+ getAfter(sequenceId) {
37
+ const results = [];
38
+ for (let i = 0; i < this.capacity; i++) {
39
+ const msg = this.buffer[i];
40
+ if (msg && msg.id > sequenceId) {
41
+ results.push(msg);
42
+ }
43
+ }
44
+ // Sort by id to ensure chronological order
45
+ results.sort((a, b) => a.id - b.id);
46
+ return results;
47
+ }
48
+ /**
49
+ * Get all messages with a timestamp greater than the given timestamp.
50
+ * Returns messages in chronological order.
51
+ */
52
+ getAfterTimestamp(ts) {
53
+ const results = [];
54
+ for (let i = 0; i < this.capacity; i++) {
55
+ const msg = this.buffer[i];
56
+ if (msg && msg.timestamp > ts) {
57
+ results.push(msg);
58
+ }
59
+ }
60
+ // Sort by id to ensure chronological order
61
+ results.sort((a, b) => a.id - b.id);
62
+ return results;
63
+ }
64
+ /**
65
+ * Get the current sequence ID (the ID of the last pushed message).
66
+ * Returns 0 if no messages have been pushed.
67
+ */
68
+ currentId() {
69
+ return this.sequenceCounter;
70
+ }
71
+ }
72
+ //# sourceMappingURL=messageBuffer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"messageBuffer.js","sourceRoot":"","sources":["../src/messageBuffer.ts"],"names":[],"mappings":"AAAA;;;GAGG;AASH,MAAM,OAAO,aAAa;IAChB,MAAM,CAA6B;IACnC,QAAQ,CAAS;IACjB,UAAU,CAAS;IACnB,eAAe,CAAS;IAEhC,YAAY,WAAmB,GAAG;QAChC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,MAAM,GAAG,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7C,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;QACpB,IAAI,CAAC,eAAe,GAAG,CAAC,CAAC;IAC3B,CAAC;IAED;;;OAGG;IACH,IAAI,CAAC,IAAY,EAAE,OAAe;QAChC,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,MAAM,OAAO,GAAoB;YAC/B,EAAE,EAAE,IAAI,CAAC,eAAe;YACxB,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;YACrB,IAAI;YACJ,OAAO;SACR,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC;QACvC,IAAI,CAAC,UAAU,GAAG,CAAC,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC;QACxD,OAAO,IAAI,CAAC,eAAe,CAAC;IAC9B,CAAC;IAED;;;OAGG;IACH,QAAQ,CAAC,UAAkB;QACzB,MAAM,OAAO,GAAsB,EAAE,CAAC;QACtC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,EAAE,EAAE,CAAC;YACvC,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC3B,IAAI,GAAG,IAAI,GAAG,CAAC,EAAE,GAAG,UAAU,EAAE,CAAC;gBAC/B,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;QACH,CAAC;QACD,2CAA2C;QAC3C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;QACpC,OAAO,OAAO,CAAC;IACjB,CAAC;IAED;;;OAGG;IACH,iBAAiB,CAAC,EAAU;QAC1B,MAAM,OAAO,GAAsB,EAAE,CAAC;QACtC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,EAAE,EAAE,CAAC;YACvC,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC3B,IAAI,GAAG,IAAI,GAAG,CAAC,SAAS,GAAG,EAAE,EAAE,CAAC;gBAC9B,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;QACH,CAAC;QACD,2CAA2C;QAC3C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;QACpC,OAAO,OAAO,CAAC;IACjB,CAAC;IAED;;;OAGG;IACH,SAAS;QACP,OAAO,IAAI,CAAC,eAAe,CAAC;IAC9B,CAAC;CACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AA8DA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAgYzD,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AACvH,wBAAsB,cAAc,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AA8DA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAkYzD,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AACvH,wBAAsB,cAAc,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC"}
package/dist/server.js CHANGED
@@ -55,6 +55,7 @@ function findDashboardDir() {
55
55
  }
56
56
  import { startCLIAuth, getAuthSession, cancelAuthSession, submitAuthCode, completeAuthSession, getSupportedProviders, } from '@agent-relay/daemon';
57
57
  import { HealthWorkerManager, getHealthPort } from './services/health-worker-manager.js';
58
+ import { MessageBuffer } from './messageBuffer.js';
58
59
  /**
59
60
  * Get the host to bind to.
60
61
  * In cloud environments, bind to '::' (IPv6 any) which also accepts IPv4 on dual-stack.
@@ -480,8 +481,11 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
480
481
  });
481
482
  };
482
483
  // Initialize spawner if enabled
483
- // Use detectWorkspacePath to find the actual repo directory in cloud workspaces
484
- const workspacePath = detectWorkspacePath(projectRoot || dataDir);
484
+ // When projectRoot is explicitly provided (e.g., via --project-root), use it directly.
485
+ // Only use detectWorkspacePath for cloud workspace auto-detection when no explicit root is given.
486
+ // This fixes #380: detectWorkspacePath could re-resolve projectRoot incorrectly when
487
+ // tool directories like ~/.nvm contain package.json markers.
488
+ const workspacePath = projectRoot || detectWorkspacePath(dataDir);
485
489
  console.log(`[dashboard] Workspace path: ${workspacePath}`);
486
490
  // When an external SpawnManager is provided (from the daemon), use it for read operations
487
491
  // (logs, worker listing, hasWorker) and route spawn/release through the SDK client.
@@ -581,14 +585,28 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
581
585
  // Track file watchers for externally-spawned worker logs (module scope to avoid duplicates)
582
586
  const fileWatchers = new Map();
583
587
  const fileLastSize = new Map();
588
+ // Message buffers for replay on reconnect
589
+ // Main buffer stores broadcast messages for the main dashboard WebSocket
590
+ const mainMessageBuffer = new MessageBuffer(500);
591
+ // Per-agent log buffers store log output for each agent (smaller capacity since per-agent)
592
+ const agentLogBuffers = new Map();
593
+ /** Get or create a log buffer for an agent */
594
+ const getAgentLogBuffer = (agentName) => {
595
+ let buffer = agentLogBuffers.get(agentName);
596
+ if (!buffer) {
597
+ buffer = new MessageBuffer(200);
598
+ agentLogBuffers.set(agentName, buffer);
599
+ }
600
+ return buffer;
601
+ };
584
602
  // Track alive status for ping/pong keepalive on main dashboard connections
585
603
  // This prevents TCP/proxy timeouts from killing idle workspace connections
586
604
  const mainClientAlive = new WeakMap();
587
605
  // Track alive status for ping/pong keepalive on bridge connections
588
606
  const bridgeClientAlive = new WeakMap();
589
- // Ping interval for main dashboard WebSocket connections (30 seconds)
590
- // Aligns with heartbeat timeout (5s heartbeat * 6 multiplier = 30s)
591
- const MAIN_PING_INTERVAL_MS = 30000;
607
+ // Ping interval for main dashboard WebSocket connections (15 seconds)
608
+ // Reduced from 30s to detect disconnects faster and minimize message loss window
609
+ const MAIN_PING_INTERVAL_MS = 15000;
592
610
  const mainPingInterval = setInterval(() => {
593
611
  wss.clients.forEach((ws) => {
594
612
  if (mainClientAlive.get(ws) === false) {
@@ -602,8 +620,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
602
620
  ws.ping();
603
621
  });
604
622
  }, MAIN_PING_INTERVAL_MS);
605
- // Ping interval for bridge WebSocket connections (30 seconds)
606
- const BRIDGE_PING_INTERVAL_MS = 30000;
623
+ // Ping interval for bridge WebSocket connections (15 seconds)
624
+ // Reduced from 30s to detect disconnects faster and minimize message loss window
625
+ const BRIDGE_PING_INTERVAL_MS = 15000;
607
626
  const bridgePingInterval = setInterval(() => {
608
627
  wssBridge.clients.forEach((ws) => {
609
628
  if (bridgeClientAlive.get(ws) === false) {
@@ -790,24 +809,43 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
790
809
  // Serve Next.js static export with .html extension handling
791
810
  app.use(express.static(dashboardDir, { extensions: ['html'] }));
792
811
  // Fallback for Next.js pages (e.g., /metrics -> /metrics.html)
793
- // These are needed when a route exists as both a directory and .html file
794
- const sendFileWithFallback = (res, filePath) => {
812
+ // These are needed when a route exists as both a directory and .html file.
813
+ // For /app/* deep links we prefer redirecting to "/" if the export is missing,
814
+ // so users don’t get stuck on a plain-text error on refresh.
815
+ const uiMissingMessage = 'Dashboard UI file not found. Please reinstall using: curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash';
816
+ const sendFileOr = (res, filePath, onError) => {
795
817
  res.sendFile(filePath, (err) => {
796
818
  if (err && !res.headersSent) {
797
- res.status(404).send('Dashboard UI file not found. Please reinstall using: curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash');
819
+ onError(err);
798
820
  }
799
821
  });
800
822
  };
823
+ const sendFileOrText404 = (res, filePath, message) => {
824
+ sendFileOr(res, filePath, () => {
825
+ res.status(404).send(message);
826
+ });
827
+ };
828
+ const sendFileOrRedirectRoot = (res, filePath) => {
829
+ sendFileOr(res, filePath, () => {
830
+ // If the app entrypoint isn’t present, try to recover by sending users
831
+ // to the root page (if it exists). Otherwise keep the install hint.
832
+ if (fs.existsSync(path.join(dashboardDir, 'index.html'))) {
833
+ res.redirect(302, '/');
834
+ return;
835
+ }
836
+ res.status(404).send(uiMissingMessage);
837
+ });
838
+ };
801
839
  app.get('/metrics', (req, res) => {
802
- sendFileWithFallback(res, path.join(dashboardDir, 'metrics.html'));
840
+ sendFileOrText404(res, path.join(dashboardDir, 'metrics.html'), uiMissingMessage);
803
841
  });
804
842
  app.get('/app', (req, res) => {
805
- sendFileWithFallback(res, path.join(dashboardDir, 'app.html'));
843
+ sendFileOrRedirectRoot(res, path.join(dashboardDir, 'app.html'));
806
844
  });
807
845
  // Catch-all for /app/* routes - serve app.html and let client-side routing handle it
808
846
  // Express 5 requires named parameter for wildcards
809
847
  app.get('/app/{*path}', (req, res) => {
810
- sendFileWithFallback(res, path.join(dashboardDir, 'app.html'));
848
+ sendFileOrRedirectRoot(res, path.join(dashboardDir, 'app.html'));
811
849
  });
812
850
  }
813
851
  else {
@@ -1796,7 +1834,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1796
1834
  // Ignore errors reading processing state - it's optional
1797
1835
  }
1798
1836
  }
1799
- // Mark spawned agents with isSpawned flag and team
1837
+ // Mark spawned agents with isSpawned flag, team, and model
1800
1838
  if (spawnReader) {
1801
1839
  const activeWorkers = spawnReader.getActiveWorkers();
1802
1840
  for (const worker of activeWorkers) {
@@ -1806,6 +1844,14 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1806
1844
  if (worker.team) {
1807
1845
  agent.team = worker.team;
1808
1846
  }
1847
+ // Extract model from spawn command (e.g., "codex --model gpt-5.2-codex" → "gpt-5.2-codex")
1848
+ if (worker.cli) {
1849
+ // Support both `--model foo` and `--model=foo`
1850
+ const modelMatch = worker.cli.match(/--model[=\s]+(\S+)/);
1851
+ if (modelMatch) {
1852
+ agent.model = modelMatch[1];
1853
+ }
1854
+ }
1809
1855
  }
1810
1856
  }
1811
1857
  }
@@ -1903,12 +1949,15 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1903
1949
  const broadcastData = async () => {
1904
1950
  try {
1905
1951
  const data = await getAllData();
1906
- const payload = JSON.stringify(data);
1952
+ const rawPayload = JSON.stringify(data);
1907
1953
  // Guard against empty/invalid payloads
1908
- if (!payload || payload.length === 0) {
1954
+ if (!rawPayload || rawPayload.length === 0) {
1909
1955
  console.warn('[dashboard] Skipping broadcast - empty payload');
1910
1956
  return;
1911
1957
  }
1958
+ // Push into buffer and wrap with sequence ID for replay support
1959
+ const seq = mainMessageBuffer.push('data', rawPayload);
1960
+ const payload = JSON.stringify({ seq, ...data });
1912
1961
  wss.clients.forEach(client => {
1913
1962
  // Skip clients that are still being initialized by the connection handler
1914
1963
  if (initializingClients.has(client)) {
@@ -2018,6 +2067,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2018
2067
  ws.on('pong', () => {
2019
2068
  mainClientAlive.set(ws, true);
2020
2069
  });
2070
+ // Send current sequence ID so client can track its position
2071
+ if (ws.readyState === WebSocket.OPEN) {
2072
+ ws.send(JSON.stringify({ type: 'sync', sequenceId: mainMessageBuffer.currentId() }));
2073
+ }
2021
2074
  // Mark as initializing to prevent broadcastData from sending before we do
2022
2075
  initializingClients.add(ws);
2023
2076
  try {
@@ -2044,6 +2097,39 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2044
2097
  // Now allow broadcastData to send to this client
2045
2098
  initializingClients.delete(ws);
2046
2099
  }
2100
+ // Handle messages from client (replay requests, etc.)
2101
+ ws.on('message', (data) => {
2102
+ try {
2103
+ const msg = JSON.parse(data.toString());
2104
+ // Handle replay request: client sends { type: "replay", lastSequenceId: N }
2105
+ if (msg.type === 'replay' && typeof msg.lastSequenceId === 'number') {
2106
+ const missed = mainMessageBuffer.getAfter(msg.lastSequenceId);
2107
+ const gapMs = missed.length > 0 ? Date.now() - missed[0].timestamp : 0;
2108
+ console.log(`[dashboard] Client replaying ${missed.length} missed messages (gap: ${gapMs}ms)`);
2109
+ // Send each missed message with its original sequence ID
2110
+ for (const buffered of missed) {
2111
+ if (ws.readyState === WebSocket.OPEN) {
2112
+ try {
2113
+ // Reconstruct the payload with the seq wrapper
2114
+ const original = JSON.parse(buffered.payload);
2115
+ ws.send(JSON.stringify({ seq: buffered.id, ...original }));
2116
+ }
2117
+ catch (err) {
2118
+ console.error('[dashboard] Failed to replay message:', err);
2119
+ }
2120
+ }
2121
+ }
2122
+ // Send current sync position after replay
2123
+ if (ws.readyState === WebSocket.OPEN) {
2124
+ ws.send(JSON.stringify({ type: 'sync', sequenceId: mainMessageBuffer.currentId() }));
2125
+ }
2126
+ }
2127
+ }
2128
+ catch (err) {
2129
+ // Non-JSON messages are ignored (binary, etc.)
2130
+ debug(`[dashboard] Unhandled main WebSocket message: ${err}`);
2131
+ }
2132
+ });
2047
2133
  ws.on('error', (err) => {
2048
2134
  console.error('[dashboard] WebSocket client error:', err);
2049
2135
  });
@@ -2079,9 +2165,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2079
2165
  });
2080
2166
  // Track alive status for ping/pong keepalive on log connections
2081
2167
  const logClientAlive = new WeakMap();
2082
- // Ping interval for log WebSocket connections (30 seconds)
2083
- // This prevents TCP/proxy timeouts from killing idle connections
2084
- const LOG_PING_INTERVAL_MS = 30000;
2168
+ // Ping interval for log WebSocket connections (15 seconds)
2169
+ // Reduced from 30s to detect disconnects faster and minimize message loss window
2170
+ const LOG_PING_INTERVAL_MS = 15000;
2085
2171
  const logPingInterval = setInterval(() => {
2086
2172
  wssLogs.clients.forEach((ws) => {
2087
2173
  if (logClientAlive.get(ws) === false) {
@@ -2109,6 +2195,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2109
2195
  ws.on('pong', () => {
2110
2196
  logClientAlive.set(ws, true);
2111
2197
  });
2198
+ // Send sync message with current server timestamp so client can track its position
2199
+ if (ws.readyState === WebSocket.OPEN) {
2200
+ ws.send(JSON.stringify({ type: 'sync', serverTimestamp: Date.now() }));
2201
+ }
2112
2202
  // Helper to check if agent is daemon-connected (from agents.json)
2113
2203
  const isDaemonConnected = (agentName) => {
2114
2204
  const agentsPath = path.join(teamDir, 'agents.json');
@@ -2188,6 +2278,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2188
2278
  data: newContent,
2189
2279
  timestamp: new Date().toISOString(),
2190
2280
  });
2281
+ // Push into per-agent log buffer for replay on reconnect
2282
+ getAgentLogBuffer(agentName).push('output', payload);
2191
2283
  for (const client of clients) {
2192
2284
  if (client.readyState === WebSocket.OPEN) {
2193
2285
  client.send(payload);
@@ -2362,6 +2454,29 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2362
2454
  }));
2363
2455
  }
2364
2456
  }
2457
+ // Handle replay request: client sends { type: "replay", agent: "name", lastTimestamp: N }
2458
+ // Logs use timestamps instead of sequence IDs since the data is raw text
2459
+ if (msg.type === 'replay' && typeof msg.agent === 'string' && typeof msg.lastTimestamp === 'number') {
2460
+ const logBuffer = agentLogBuffers.get(msg.agent);
2461
+ if (logBuffer) {
2462
+ const missed = logBuffer.getAfterTimestamp(msg.lastTimestamp);
2463
+ const gapMs = missed.length > 0 ? Date.now() - missed[0].timestamp : 0;
2464
+ console.log(`[dashboard] Client replaying ${missed.length} missed log messages for ${msg.agent} (gap: ${gapMs}ms)`);
2465
+ // Send replay as a structured response the client expects
2466
+ if (ws.readyState === WebSocket.OPEN) {
2467
+ try {
2468
+ const entries = missed.map(m => ({
2469
+ content: m.payload,
2470
+ timestamp: m.timestamp,
2471
+ }));
2472
+ ws.send(JSON.stringify({ type: 'replay', entries }));
2473
+ }
2474
+ catch (err) {
2475
+ console.error('[dashboard] Failed to replay log messages:', err);
2476
+ }
2477
+ }
2478
+ }
2479
+ }
2365
2480
  }
2366
2481
  catch (err) {
2367
2482
  console.error('[dashboard] Invalid logs WebSocket message:', err);
@@ -2382,6 +2497,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2382
2497
  watcher.close();
2383
2498
  fileWatchers.delete(agentName);
2384
2499
  fileLastSize.delete(agentName);
2500
+ agentLogBuffers.delete(agentName);
2385
2501
  console.log(`[dashboard] Stopped watching log file for: ${agentName}`);
2386
2502
  }
2387
2503
  }
@@ -2435,12 +2551,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2435
2551
  agentHashes.delete(oldest);
2436
2552
  }
2437
2553
  }
2438
- const payload = JSON.stringify({
2554
+ const logPayload = {
2439
2555
  type: 'output',
2440
2556
  agent: agentName,
2441
2557
  data: output,
2442
2558
  timestamp: new Date().toISOString(),
2443
- });
2559
+ };
2560
+ const payload = JSON.stringify(logPayload);
2561
+ // Push into per-agent log buffer for replay on reconnect
2562
+ // Logs use timestamps instead of sequence IDs since the data is raw text
2563
+ getAgentLogBuffer(agentName).push('output', payload);
2444
2564
  for (const client of clients) {
2445
2565
  if (client.readyState === WebSocket.OPEN) {
2446
2566
  client.send(payload);
@@ -2462,7 +2582,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2462
2582
  // Helper to broadcast channel messages to all connected clients
2463
2583
  // Broadcasts to both main wss (local mode) and wssPresence (cloud mode)
2464
2584
  const broadcastChannelMessage = (message) => {
2465
- const payload = JSON.stringify(message);
2585
+ // Push into buffer and wrap with sequence ID for replay support
2586
+ const rawPayload = JSON.stringify(message);
2587
+ const seq = mainMessageBuffer.push('channel_message', rawPayload);
2588
+ const payload = JSON.stringify({ seq, ...message });
2466
2589
  // Broadcast to main WebSocket clients (local mode)
2467
2590
  wss.clients.forEach((client) => {
2468
2591
  if (client.readyState === WebSocket.OPEN) {
@@ -2480,7 +2603,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2480
2603
  // This enables agent replies to appear in the dashboard UI
2481
2604
  // Broadcasts to both main wss (local mode) and wssPresence (cloud mode)
2482
2605
  const broadcastDirectMessage = (message) => {
2483
- const payload = JSON.stringify(message);
2606
+ // Push into buffer and wrap with sequence ID for replay support
2607
+ const rawPayload = JSON.stringify(message);
2608
+ const seq = mainMessageBuffer.push('direct_message', rawPayload);
2609
+ const payload = JSON.stringify({ seq, ...message });
2484
2610
  // Broadcast to main WebSocket clients (local mode)
2485
2611
  const mainClients = Array.from(wss.clients).filter(c => c.readyState === WebSocket.OPEN);
2486
2612
  debug(`[dashboard] Broadcasting direct_message to ${mainClients.length} main clients`);