@agent-relay/dashboard-server 2.0.64 → 2.0.66-beta.0

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 (81) 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 +219 -41
  7. package/dist/server.js.map +1 -1
  8. package/out/404.html +1 -1
  9. package/out/_next/static/chunks/118-4c8241b0218335de.js +1 -0
  10. package/out/_next/static/chunks/{202-6cfbf8339f05e5ef.js → 202-fc0763dd7488e58f.js} +1 -1
  11. package/out/_next/static/chunks/259-3bbaad41b2550936.js +1 -0
  12. package/out/_next/static/chunks/285-1cb1c0ed74f31c6c.js +1 -0
  13. package/out/_next/static/chunks/722-85011b58b9caf88b.js +1 -0
  14. package/out/_next/static/chunks/994-0ce5f1d759089504.js +1 -0
  15. package/out/_next/static/chunks/app/app/[[...slug]]/page-589620c567f85400.js +1 -0
  16. package/out/_next/static/chunks/app/{page-a32b25323fff7aa0.js → page-5c60a00d938ac40a.js} +1 -1
  17. package/out/_next/static/chunks/app/providers/setup/[provider]/page-f058bf6696242d7b.js +1 -0
  18. package/out/_next/static/css/71615414d8909a44.css +1 -0
  19. package/out/about.html +2 -2
  20. package/out/about.txt +1 -1
  21. package/out/app/onboarding.html +1 -1
  22. package/out/app/onboarding.txt +1 -1
  23. package/out/app.html +1 -1
  24. package/out/app.txt +2 -2
  25. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
  26. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  27. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  28. package/out/blog/let-them-cook-multi-agent-orchestration.txt +1 -1
  29. package/out/blog.html +2 -2
  30. package/out/blog.txt +1 -1
  31. package/out/careers.html +2 -2
  32. package/out/careers.txt +1 -1
  33. package/out/changelog.html +2 -2
  34. package/out/changelog.txt +1 -1
  35. package/out/cloud/link.html +1 -1
  36. package/out/cloud/link.txt +2 -2
  37. package/out/complete-profile.html +2 -2
  38. package/out/complete-profile.txt +1 -1
  39. package/out/connect-repos.html +1 -1
  40. package/out/connect-repos.txt +2 -2
  41. package/out/contact.html +2 -2
  42. package/out/contact.txt +1 -1
  43. package/out/docs.html +2 -2
  44. package/out/docs.txt +1 -1
  45. package/out/history.html +1 -1
  46. package/out/history.txt +2 -2
  47. package/out/index.html +1 -1
  48. package/out/index.txt +2 -2
  49. package/out/login.html +2 -2
  50. package/out/login.txt +2 -2
  51. package/out/metrics.html +1 -1
  52. package/out/metrics.txt +2 -2
  53. package/out/pricing.html +2 -2
  54. package/out/pricing.txt +1 -1
  55. package/out/privacy.html +2 -2
  56. package/out/privacy.txt +1 -1
  57. package/out/providers/setup/claude.html +1 -1
  58. package/out/providers/setup/claude.txt +2 -2
  59. package/out/providers/setup/codex.html +1 -1
  60. package/out/providers/setup/codex.txt +2 -2
  61. package/out/providers/setup/cursor.html +1 -1
  62. package/out/providers/setup/cursor.txt +2 -2
  63. package/out/providers.html +1 -1
  64. package/out/providers.txt +2 -2
  65. package/out/security.html +2 -2
  66. package/out/security.txt +1 -1
  67. package/out/signup.html +2 -2
  68. package/out/signup.txt +2 -2
  69. package/out/terms.html +2 -2
  70. package/out/terms.txt +1 -1
  71. package/package.json +1 -1
  72. package/out/_next/static/chunks/118-b821e49d30a9f6af.js +0 -1
  73. package/out/_next/static/chunks/259-b560f20df53128e5.js +0 -1
  74. package/out/_next/static/chunks/722-6cffbc5120f31e24.js +0 -1
  75. package/out/_next/static/chunks/873-9aee36b975a9556a.js +0 -1
  76. package/out/_next/static/chunks/994-e927457424324a78.js +0 -1
  77. package/out/_next/static/chunks/app/app/[[...slug]]/page-a528040db9d1fec0.js +0 -1
  78. package/out/_next/static/chunks/app/providers/setup/[provider]/page-c667546c4902f1b0.js +0 -1
  79. package/out/_next/static/css/2ee05ba949b3ac9f.css +0 -1
  80. /package/out/_next/static/{6oI4iquYj1QbK8njLsK3s → CRgdkwuTcA6Bt0A5Fx1wC}/_buildManifest.js +0 -0
  81. /package/out/_next/static/{6oI4iquYj1QbK8njLsK3s → CRgdkwuTcA6Bt0A5Fx1wC}/_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;AAmYzD,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
@@ -5,7 +5,7 @@ import path from 'path';
5
5
  import fs from 'fs';
6
6
  import os from 'os';
7
7
  import crypto from 'crypto';
8
- import { exec } from 'child_process';
8
+ import { exec, execFile } from 'child_process';
9
9
  import { fileURLToPath } from 'url';
10
10
  import { createStorageAdapter } from '@agent-relay/storage/adapter';
11
11
  import { RelayClient } from '@agent-relay/sdk';
@@ -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) {
@@ -623,6 +642,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
623
642
  clearInterval(bridgePingInterval);
624
643
  });
625
644
  const onlineUsers = new Map();
645
+ // Track cwd per spawned agent (name -> cwd)
646
+ // This is set when /api/spawn is called and included in /api/spawned responses
647
+ const agentCwdMap = new Map();
626
648
  // Validation helpers for presence
627
649
  const isValidUsername = (username) => {
628
650
  if (typeof username !== 'string')
@@ -1815,7 +1837,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1815
1837
  // Ignore errors reading processing state - it's optional
1816
1838
  }
1817
1839
  }
1818
- // Mark spawned agents with isSpawned flag, team, and model
1840
+ // Mark spawned agents with isSpawned flag, team, model, and cwd
1819
1841
  if (spawnReader) {
1820
1842
  const activeWorkers = spawnReader.getActiveWorkers();
1821
1843
  for (const worker of activeWorkers) {
@@ -1825,9 +1847,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1825
1847
  if (worker.team) {
1826
1848
  agent.team = worker.team;
1827
1849
  }
1850
+ // Inject cwd from agentCwdMap (set during /api/spawn) or from worker info
1851
+ // (set by SpawnManager for relay-protocol spawns that bypass /api/spawn)
1852
+ const workerCwd = agentCwdMap.get(worker.name) || worker.cwd;
1853
+ if (workerCwd) {
1854
+ agent.cwd = workerCwd;
1855
+ }
1828
1856
  // Extract model from spawn command (e.g., "codex --model gpt-5.2-codex" → "gpt-5.2-codex")
1829
1857
  if (worker.cli) {
1830
- const modelMatch = worker.cli.match(/--model\s+(\S+)/);
1858
+ // Support both `--model foo` and `--model=foo`
1859
+ const modelMatch = worker.cli.match(/--model[=\s]+(\S+)/);
1831
1860
  if (modelMatch) {
1832
1861
  agent.model = modelMatch[1];
1833
1862
  }
@@ -1852,6 +1881,21 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1852
1881
  // Ignore errors reading workers.json
1853
1882
  }
1854
1883
  }
1884
+ // Mark relay-protocol spawned agents (spawned by other agents, not via dashboard /api/spawn)
1885
+ // These agents have log files in the team directory but aren't tracked by agentCwdMap
1886
+ if (spawnReader) {
1887
+ for (const [name, agent] of agentsMap) {
1888
+ if (agent.isSpawned)
1889
+ continue;
1890
+ if (onlineUsers.has(name) || name === 'Dashboard')
1891
+ continue;
1892
+ // Check if there's a log file for this agent (indicates it was spawned)
1893
+ const logPath = path.join(teamDir, `${name}.log`);
1894
+ if (fs.existsSync(logPath)) {
1895
+ agent.isSpawned = true;
1896
+ }
1897
+ }
1898
+ }
1855
1899
  // Set team from teams.json for agents that don't have a team yet
1856
1900
  // This ensures agents defined in teams.json are associated with their team
1857
1901
  // even if they weren't spawned via auto-spawn
@@ -1929,12 +1973,15 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
1929
1973
  const broadcastData = async () => {
1930
1974
  try {
1931
1975
  const data = await getAllData();
1932
- const payload = JSON.stringify(data);
1976
+ const rawPayload = JSON.stringify(data);
1933
1977
  // Guard against empty/invalid payloads
1934
- if (!payload || payload.length === 0) {
1978
+ if (!rawPayload || rawPayload.length === 0) {
1935
1979
  console.warn('[dashboard] Skipping broadcast - empty payload');
1936
1980
  return;
1937
1981
  }
1982
+ // Push into buffer and wrap with sequence ID for replay support
1983
+ const seq = mainMessageBuffer.push('data', rawPayload);
1984
+ const payload = JSON.stringify({ seq, ...data });
1938
1985
  wss.clients.forEach(client => {
1939
1986
  // Skip clients that are still being initialized by the connection handler
1940
1987
  if (initializingClients.has(client)) {
@@ -2044,6 +2091,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2044
2091
  ws.on('pong', () => {
2045
2092
  mainClientAlive.set(ws, true);
2046
2093
  });
2094
+ // Send current sequence ID so client can track its position
2095
+ if (ws.readyState === WebSocket.OPEN) {
2096
+ ws.send(JSON.stringify({ type: 'sync', sequenceId: mainMessageBuffer.currentId() }));
2097
+ }
2047
2098
  // Mark as initializing to prevent broadcastData from sending before we do
2048
2099
  initializingClients.add(ws);
2049
2100
  try {
@@ -2070,6 +2121,39 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2070
2121
  // Now allow broadcastData to send to this client
2071
2122
  initializingClients.delete(ws);
2072
2123
  }
2124
+ // Handle messages from client (replay requests, etc.)
2125
+ ws.on('message', (data) => {
2126
+ try {
2127
+ const msg = JSON.parse(data.toString());
2128
+ // Handle replay request: client sends { type: "replay", lastSequenceId: N }
2129
+ if (msg.type === 'replay' && typeof msg.lastSequenceId === 'number') {
2130
+ const missed = mainMessageBuffer.getAfter(msg.lastSequenceId);
2131
+ const gapMs = missed.length > 0 ? Date.now() - missed[0].timestamp : 0;
2132
+ console.log(`[dashboard] Client replaying ${missed.length} missed messages (gap: ${gapMs}ms)`);
2133
+ // Send each missed message with its original sequence ID
2134
+ for (const buffered of missed) {
2135
+ if (ws.readyState === WebSocket.OPEN) {
2136
+ try {
2137
+ // Reconstruct the payload with the seq wrapper
2138
+ const original = JSON.parse(buffered.payload);
2139
+ ws.send(JSON.stringify({ seq: buffered.id, ...original }));
2140
+ }
2141
+ catch (err) {
2142
+ console.error('[dashboard] Failed to replay message:', err);
2143
+ }
2144
+ }
2145
+ }
2146
+ // Send current sync position after replay
2147
+ if (ws.readyState === WebSocket.OPEN) {
2148
+ ws.send(JSON.stringify({ type: 'sync', sequenceId: mainMessageBuffer.currentId() }));
2149
+ }
2150
+ }
2151
+ }
2152
+ catch (err) {
2153
+ // Non-JSON messages are ignored (binary, etc.)
2154
+ debug(`[dashboard] Unhandled main WebSocket message: ${err}`);
2155
+ }
2156
+ });
2073
2157
  ws.on('error', (err) => {
2074
2158
  console.error('[dashboard] WebSocket client error:', err);
2075
2159
  });
@@ -2105,9 +2189,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2105
2189
  });
2106
2190
  // Track alive status for ping/pong keepalive on log connections
2107
2191
  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;
2192
+ // Ping interval for log WebSocket connections (15 seconds)
2193
+ // Reduced from 30s to detect disconnects faster and minimize message loss window
2194
+ const LOG_PING_INTERVAL_MS = 15000;
2111
2195
  const logPingInterval = setInterval(() => {
2112
2196
  wssLogs.clients.forEach((ws) => {
2113
2197
  if (logClientAlive.get(ws) === false) {
@@ -2135,6 +2219,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2135
2219
  ws.on('pong', () => {
2136
2220
  logClientAlive.set(ws, true);
2137
2221
  });
2222
+ // Send sync message with current server timestamp so client can track its position
2223
+ if (ws.readyState === WebSocket.OPEN) {
2224
+ ws.send(JSON.stringify({ type: 'sync', serverTimestamp: Date.now() }));
2225
+ }
2138
2226
  // Helper to check if agent is daemon-connected (from agents.json)
2139
2227
  const isDaemonConnected = (agentName) => {
2140
2228
  const agentsPath = path.join(teamDir, 'agents.json');
@@ -2214,6 +2302,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2214
2302
  data: newContent,
2215
2303
  timestamp: new Date().toISOString(),
2216
2304
  });
2305
+ // Push into per-agent log buffer for replay on reconnect
2306
+ getAgentLogBuffer(agentName).push('output', payload);
2217
2307
  for (const client of clients) {
2218
2308
  if (client.readyState === WebSocket.OPEN) {
2219
2309
  client.send(payload);
@@ -2388,6 +2478,29 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2388
2478
  }));
2389
2479
  }
2390
2480
  }
2481
+ // Handle replay request: client sends { type: "replay", agent: "name", lastTimestamp: N }
2482
+ // Logs use timestamps instead of sequence IDs since the data is raw text
2483
+ if (msg.type === 'replay' && typeof msg.agent === 'string' && typeof msg.lastTimestamp === 'number') {
2484
+ const logBuffer = agentLogBuffers.get(msg.agent);
2485
+ if (logBuffer) {
2486
+ const missed = logBuffer.getAfterTimestamp(msg.lastTimestamp);
2487
+ const gapMs = missed.length > 0 ? Date.now() - missed[0].timestamp : 0;
2488
+ console.log(`[dashboard] Client replaying ${missed.length} missed log messages for ${msg.agent} (gap: ${gapMs}ms)`);
2489
+ // Send replay as a structured response the client expects
2490
+ if (ws.readyState === WebSocket.OPEN) {
2491
+ try {
2492
+ const entries = missed.map(m => ({
2493
+ content: m.payload,
2494
+ timestamp: m.timestamp,
2495
+ }));
2496
+ ws.send(JSON.stringify({ type: 'replay', entries }));
2497
+ }
2498
+ catch (err) {
2499
+ console.error('[dashboard] Failed to replay log messages:', err);
2500
+ }
2501
+ }
2502
+ }
2503
+ }
2391
2504
  }
2392
2505
  catch (err) {
2393
2506
  console.error('[dashboard] Invalid logs WebSocket message:', err);
@@ -2408,6 +2521,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2408
2521
  watcher.close();
2409
2522
  fileWatchers.delete(agentName);
2410
2523
  fileLastSize.delete(agentName);
2524
+ agentLogBuffers.delete(agentName);
2411
2525
  console.log(`[dashboard] Stopped watching log file for: ${agentName}`);
2412
2526
  }
2413
2527
  }
@@ -2461,12 +2575,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2461
2575
  agentHashes.delete(oldest);
2462
2576
  }
2463
2577
  }
2464
- const payload = JSON.stringify({
2578
+ const logPayload = {
2465
2579
  type: 'output',
2466
2580
  agent: agentName,
2467
2581
  data: output,
2468
2582
  timestamp: new Date().toISOString(),
2469
- });
2583
+ };
2584
+ const payload = JSON.stringify(logPayload);
2585
+ // Push into per-agent log buffer for replay on reconnect
2586
+ // Logs use timestamps instead of sequence IDs since the data is raw text
2587
+ getAgentLogBuffer(agentName).push('output', payload);
2470
2588
  for (const client of clients) {
2471
2589
  if (client.readyState === WebSocket.OPEN) {
2472
2590
  client.send(payload);
@@ -2488,7 +2606,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2488
2606
  // Helper to broadcast channel messages to all connected clients
2489
2607
  // Broadcasts to both main wss (local mode) and wssPresence (cloud mode)
2490
2608
  const broadcastChannelMessage = (message) => {
2491
- const payload = JSON.stringify(message);
2609
+ // Push into buffer and wrap with sequence ID for replay support
2610
+ const rawPayload = JSON.stringify(message);
2611
+ const seq = mainMessageBuffer.push('channel_message', rawPayload);
2612
+ const payload = JSON.stringify({ seq, ...message });
2492
2613
  // Broadcast to main WebSocket clients (local mode)
2493
2614
  wss.clients.forEach((client) => {
2494
2615
  if (client.readyState === WebSocket.OPEN) {
@@ -2506,7 +2627,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
2506
2627
  // This enables agent replies to appear in the dashboard UI
2507
2628
  // Broadcasts to both main wss (local mode) and wssPresence (cloud mode)
2508
2629
  const broadcastDirectMessage = (message) => {
2509
- const payload = JSON.stringify(message);
2630
+ // Push into buffer and wrap with sequence ID for replay support
2631
+ const rawPayload = JSON.stringify(message);
2632
+ const seq = mainMessageBuffer.push('direct_message', rawPayload);
2633
+ const payload = JSON.stringify({ seq, ...message });
2510
2634
  // Broadcast to main WebSocket clients (local mode)
2511
2635
  const mainClients = Array.from(wss.clients).filter(c => c.readyState === WebSocket.OPEN);
2512
2636
  debug(`[dashboard] Broadcasting direct_message to ${mainClients.length} main clients`);
@@ -4067,26 +4191,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4067
4191
  });
4068
4192
  }
4069
4193
  }
4070
- // Also check agents.json for registered agents that may not be spawned
4071
- const agentsPath = path.join(teamDir, 'agents.json');
4072
- if (fs.existsSync(agentsPath)) {
4073
- const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
4074
- const registeredAgents = data.agents || [];
4075
- for (const agent of registeredAgents) {
4076
- if (!agents.find(a => a.name === agent.name)) {
4077
- // Check if recently active (within 30 seconds)
4078
- const lastSeen = agent.lastSeen ? new Date(agent.lastSeen).getTime() : 0;
4079
- const isActive = Date.now() - lastSeen < 30000;
4080
- if (isActive) {
4081
- agents.push({
4082
- name: agent.name,
4083
- status: 'active',
4084
- alertLevel: 'normal',
4085
- });
4086
- }
4087
- }
4088
- }
4089
- }
4194
+ // Note: We only show spawned agents with actual PIDs in memory metrics.
4195
+ // Human users and non-process entries from agents.json are excluded since
4196
+ // they don't have memory usage to track.
4090
4197
  res.json({
4091
4198
  agents,
4092
4199
  system: {
@@ -4564,6 +4671,20 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4564
4671
  const online = isAgentOnline(name);
4565
4672
  res.json({ name, online });
4566
4673
  });
4674
+ /**
4675
+ * PUT /api/agents/:name/cwd - Register an agent's working directory
4676
+ * Used by relay-pty-orchestrator after daemon socket spawns (which bypass /api/spawn).
4677
+ */
4678
+ app.put('/api/agents/:name/cwd', (req, res) => {
4679
+ const { name } = req.params;
4680
+ const { cwd } = req.body || {};
4681
+ if (!cwd || typeof cwd !== 'string') {
4682
+ return res.status(400).json({ error: 'Missing required field: cwd' });
4683
+ }
4684
+ agentCwdMap.set(name, cwd);
4685
+ broadcastData().catch(() => { });
4686
+ res.json({ success: true, name, cwd });
4687
+ });
4567
4688
  // ===== Agent Spawn API =====
4568
4689
  /**
4569
4690
  * POST /api/spawn - Spawn a new agent
@@ -4583,6 +4704,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4583
4704
  error: 'Missing required field: name',
4584
4705
  });
4585
4706
  }
4707
+ // Inherit spawner's cwd if no explicit cwd provided (for nested/agent-to-agent spawns)
4708
+ const effectiveCwd = cwd || (spawnerName ? agentCwdMap.get(spawnerName) : undefined);
4586
4709
  try {
4587
4710
  let result;
4588
4711
  if (useExternalSpawnManager) {
@@ -4602,7 +4725,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4602
4725
  cli,
4603
4726
  task,
4604
4727
  team: team || undefined,
4605
- cwd: cwd || undefined,
4728
+ cwd: effectiveCwd || undefined,
4606
4729
  interactive,
4607
4730
  shadowMode,
4608
4731
  shadowAgent,
@@ -4622,7 +4745,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4622
4745
  task,
4623
4746
  team: team || undefined,
4624
4747
  spawnerName: spawnerName || undefined,
4625
- cwd: cwd || undefined,
4748
+ cwd: effectiveCwd || undefined,
4626
4749
  interactive,
4627
4750
  shadowMode,
4628
4751
  shadowAgent,
@@ -4635,6 +4758,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4635
4758
  result = await spawner.spawn(request);
4636
4759
  }
4637
4760
  if (result.success) {
4761
+ // Track cwd for this agent so /api/spawned can return it
4762
+ if (effectiveCwd) {
4763
+ agentCwdMap.set(name, effectiveCwd);
4764
+ }
4638
4765
  // Broadcast update to WebSocket clients
4639
4766
  broadcastData().catch(() => { });
4640
4767
  // Broadcast agent_spawned event to activity feed
@@ -4658,6 +4785,55 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
4658
4785
  });
4659
4786
  }
4660
4787
  });
4788
+ /**
4789
+ * POST /api/repos/clone - Clone a repo into the workspace directory
4790
+ * Body: { fullName: "Owner/RepoName" }
4791
+ * Used by cloud API to hot-clone repos added to a running workspace.
4792
+ */
4793
+ app.post('/api/repos/clone', async (req, res) => {
4794
+ const { fullName } = req.body;
4795
+ if (!fullName || typeof fullName !== 'string' || !fullName.includes('/')) {
4796
+ return res.status(400).json({ success: false, error: 'fullName is required (e.g., "Owner/RepoName")' });
4797
+ }
4798
+ // Validate format: "Owner/RepoName" with safe characters only
4799
+ if (!/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(fullName)) {
4800
+ return res.status(400).json({ success: false, error: 'Invalid repository name format' });
4801
+ }
4802
+ const repoName = fullName.split('/').pop();
4803
+ const workspaceDir = process.env.WORKSPACE_DIR || path.dirname(projectRoot || dataDir);
4804
+ const targetDir = path.join(workspaceDir, repoName);
4805
+ // Idempotent: skip if already cloned
4806
+ if (fs.existsSync(targetDir)) {
4807
+ return res.json({ success: true, message: 'Already cloned', path: targetDir });
4808
+ }
4809
+ const githubToken = process.env.GITHUB_TOKEN;
4810
+ if (!githubToken) {
4811
+ return res.status(500).json({ success: false, error: 'GITHUB_TOKEN not available' });
4812
+ }
4813
+ const cloneUrl = `https://x-access-token:${githubToken}@github.com/${fullName}.git`;
4814
+ try {
4815
+ // Use execFile to avoid shell injection
4816
+ await new Promise((resolve, reject) => {
4817
+ execFile('git', ['clone', cloneUrl, targetDir], { timeout: 120000 }, (error, _stdout, stderr) => {
4818
+ if (error) {
4819
+ reject(new Error(stderr || error.message));
4820
+ }
4821
+ else {
4822
+ resolve();
4823
+ }
4824
+ });
4825
+ });
4826
+ // Mark directory as safe for git
4827
+ execFile('git', ['config', '--global', '--add', 'safe.directory', targetDir], () => { });
4828
+ res.json({ success: true, path: targetDir });
4829
+ }
4830
+ catch (err) {
4831
+ // Sanitize error message to avoid leaking GITHUB_TOKEN embedded in the clone URL
4832
+ const safeMessage = (err.message || 'Clone failed').replace(/https:\/\/[^@]+@/g, 'https://***@');
4833
+ console.error('[api/repos/clone] Clone failed:', safeMessage);
4834
+ res.status(500).json({ success: false, error: safeMessage });
4835
+ }
4836
+ });
4661
4837
  /**
4662
4838
  * POST /api/spawn/architect - Spawn an Architect agent for bridge mode
4663
4839
  * Body: { cli?: string }
@@ -4808,6 +4984,7 @@ Start by greeting the project leads and asking for status updates.`;
4808
4984
  spawnedAt: worker.spawnedAt,
4809
4985
  task: worker.task,
4810
4986
  team: worker.team,
4987
+ cwd: agentCwdMap.get(worker.name) || worker.cwd,
4811
4988
  source: 'spawner',
4812
4989
  });
4813
4990
  }
@@ -4878,6 +5055,7 @@ Start by greeting the project leads and asking for status updates.`;
4878
5055
  released = await spawner.release(name);
4879
5056
  }
4880
5057
  if (released) {
5058
+ agentCwdMap.delete(name);
4881
5059
  broadcastData().catch(() => { });
4882
5060
  // Broadcast agent_released event to activity feed
4883
5061
  broadcastPresence({