@agent-relay/dashboard-server 2.0.64 → 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 +117 -17
  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 +1 -1
  66. package/out/_next/static/chunks/873-9aee36b975a9556a.js +0 -1
  67. package/out/_next/static/css/2ee05ba949b3ac9f.css +0 -1
  68. /package/out/_next/static/{6oI4iquYj1QbK8njLsK3s → XAoBjrJ3N72573Ty4Ja_J}/_buildManifest.js +0 -0
  69. /package/out/_next/static/{6oI4iquYj1QbK8njLsK3s → 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;AAiYzD,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) {
@@ -1827,7 +1846,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1827
1846
  }
1828
1847
  // Extract model from spawn command (e.g., "codex --model gpt-5.2-codex" → "gpt-5.2-codex")
1829
1848
  if (worker.cli) {
1830
- const modelMatch = worker.cli.match(/--model\s+(\S+)/);
1849
+ // Support both `--model foo` and `--model=foo`
1850
+ const modelMatch = worker.cli.match(/--model[=\s]+(\S+)/);
1831
1851
  if (modelMatch) {
1832
1852
  agent.model = modelMatch[1];
1833
1853
  }
@@ -1929,12 +1949,15 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1929
1949
  const broadcastData = async () => {
1930
1950
  try {
1931
1951
  const data = await getAllData();
1932
- const payload = JSON.stringify(data);
1952
+ const rawPayload = JSON.stringify(data);
1933
1953
  // Guard against empty/invalid payloads
1934
- if (!payload || payload.length === 0) {
1954
+ if (!rawPayload || rawPayload.length === 0) {
1935
1955
  console.warn('[dashboard] Skipping broadcast - empty payload');
1936
1956
  return;
1937
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 });
1938
1961
  wss.clients.forEach(client => {
1939
1962
  // Skip clients that are still being initialized by the connection handler
1940
1963
  if (initializingClients.has(client)) {
@@ -2044,6 +2067,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2044
2067
  ws.on('pong', () => {
2045
2068
  mainClientAlive.set(ws, true);
2046
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
+ }
2047
2074
  // Mark as initializing to prevent broadcastData from sending before we do
2048
2075
  initializingClients.add(ws);
2049
2076
  try {
@@ -2070,6 +2097,39 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2070
2097
  // Now allow broadcastData to send to this client
2071
2098
  initializingClients.delete(ws);
2072
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
+ });
2073
2133
  ws.on('error', (err) => {
2074
2134
  console.error('[dashboard] WebSocket client error:', err);
2075
2135
  });
@@ -2105,9 +2165,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2105
2165
  });
2106
2166
  // Track alive status for ping/pong keepalive on log connections
2107
2167
  const logClientAlive = new WeakMap();
2108
- // Ping interval for log WebSocket connections (30 seconds)
2109
- // This prevents TCP/proxy timeouts from killing idle connections
2110
- 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;
2111
2171
  const logPingInterval = setInterval(() => {
2112
2172
  wssLogs.clients.forEach((ws) => {
2113
2173
  if (logClientAlive.get(ws) === false) {
@@ -2135,6 +2195,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2135
2195
  ws.on('pong', () => {
2136
2196
  logClientAlive.set(ws, true);
2137
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
+ }
2138
2202
  // Helper to check if agent is daemon-connected (from agents.json)
2139
2203
  const isDaemonConnected = (agentName) => {
2140
2204
  const agentsPath = path.join(teamDir, 'agents.json');
@@ -2214,6 +2278,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2214
2278
  data: newContent,
2215
2279
  timestamp: new Date().toISOString(),
2216
2280
  });
2281
+ // Push into per-agent log buffer for replay on reconnect
2282
+ getAgentLogBuffer(agentName).push('output', payload);
2217
2283
  for (const client of clients) {
2218
2284
  if (client.readyState === WebSocket.OPEN) {
2219
2285
  client.send(payload);
@@ -2388,6 +2454,29 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2388
2454
  }));
2389
2455
  }
2390
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
+ }
2391
2480
  }
2392
2481
  catch (err) {
2393
2482
  console.error('[dashboard] Invalid logs WebSocket message:', err);
@@ -2408,6 +2497,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2408
2497
  watcher.close();
2409
2498
  fileWatchers.delete(agentName);
2410
2499
  fileLastSize.delete(agentName);
2500
+ agentLogBuffers.delete(agentName);
2411
2501
  console.log(`[dashboard] Stopped watching log file for: ${agentName}`);
2412
2502
  }
2413
2503
  }
@@ -2461,12 +2551,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2461
2551
  agentHashes.delete(oldest);
2462
2552
  }
2463
2553
  }
2464
- const payload = JSON.stringify({
2554
+ const logPayload = {
2465
2555
  type: 'output',
2466
2556
  agent: agentName,
2467
2557
  data: output,
2468
2558
  timestamp: new Date().toISOString(),
2469
- });
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);
2470
2564
  for (const client of clients) {
2471
2565
  if (client.readyState === WebSocket.OPEN) {
2472
2566
  client.send(payload);
@@ -2488,7 +2582,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2488
2582
  // Helper to broadcast channel messages to all connected clients
2489
2583
  // Broadcasts to both main wss (local mode) and wssPresence (cloud mode)
2490
2584
  const broadcastChannelMessage = (message) => {
2491
- 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 });
2492
2589
  // Broadcast to main WebSocket clients (local mode)
2493
2590
  wss.clients.forEach((client) => {
2494
2591
  if (client.readyState === WebSocket.OPEN) {
@@ -2506,7 +2603,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2506
2603
  // This enables agent replies to appear in the dashboard UI
2507
2604
  // Broadcasts to both main wss (local mode) and wssPresence (cloud mode)
2508
2605
  const broadcastDirectMessage = (message) => {
2509
- 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 });
2510
2610
  // Broadcast to main WebSocket clients (local mode)
2511
2611
  const mainClients = Array.from(wss.clients).filter(c => c.readyState === WebSocket.OPEN);
2512
2612
  debug(`[dashboard] Broadcasting direct_message to ${mainClients.length} main clients`);