agent-relay 1.0.21 → 1.1.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 (283) hide show
  1. package/dist/bridge/shadow-cli.d.ts +17 -0
  2. package/dist/bridge/shadow-cli.d.ts.map +1 -0
  3. package/dist/bridge/shadow-cli.js +75 -0
  4. package/dist/bridge/shadow-cli.js.map +1 -0
  5. package/dist/bridge/shadow-config.d.ts +87 -0
  6. package/dist/bridge/shadow-config.d.ts.map +1 -0
  7. package/dist/bridge/shadow-config.js +134 -0
  8. package/dist/bridge/shadow-config.js.map +1 -0
  9. package/dist/bridge/spawner.d.ts +15 -1
  10. package/dist/bridge/spawner.d.ts.map +1 -1
  11. package/dist/bridge/spawner.js +164 -4
  12. package/dist/bridge/spawner.js.map +1 -1
  13. package/dist/bridge/types.d.ts +55 -0
  14. package/dist/bridge/types.d.ts.map +1 -1
  15. package/dist/cli/index.js +796 -11
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/cloud/api/auth.d.ts +19 -0
  18. package/dist/cloud/api/auth.d.ts.map +1 -0
  19. package/dist/cloud/api/auth.js +216 -0
  20. package/dist/cloud/api/auth.js.map +1 -0
  21. package/dist/cloud/api/billing.d.ts +17 -0
  22. package/dist/cloud/api/billing.d.ts.map +1 -0
  23. package/dist/cloud/api/billing.js +353 -0
  24. package/dist/cloud/api/billing.js.map +1 -0
  25. package/dist/cloud/api/coordinators.d.ts +8 -0
  26. package/dist/cloud/api/coordinators.d.ts.map +1 -0
  27. package/dist/cloud/api/coordinators.js +347 -0
  28. package/dist/cloud/api/coordinators.js.map +1 -0
  29. package/dist/cloud/api/daemons.d.ts +12 -0
  30. package/dist/cloud/api/daemons.d.ts.map +1 -0
  31. package/dist/cloud/api/daemons.js +320 -0
  32. package/dist/cloud/api/daemons.js.map +1 -0
  33. package/dist/cloud/api/middleware/planLimits.d.ts +36 -0
  34. package/dist/cloud/api/middleware/planLimits.d.ts.map +1 -0
  35. package/dist/cloud/api/middleware/planLimits.js +164 -0
  36. package/dist/cloud/api/middleware/planLimits.js.map +1 -0
  37. package/dist/cloud/api/onboarding.d.ts +8 -0
  38. package/dist/cloud/api/onboarding.d.ts.map +1 -0
  39. package/dist/cloud/api/onboarding.js +407 -0
  40. package/dist/cloud/api/onboarding.js.map +1 -0
  41. package/dist/cloud/api/providers.d.ts +7 -0
  42. package/dist/cloud/api/providers.d.ts.map +1 -0
  43. package/dist/cloud/api/providers.js +435 -0
  44. package/dist/cloud/api/providers.js.map +1 -0
  45. package/dist/cloud/api/repos.d.ts +7 -0
  46. package/dist/cloud/api/repos.d.ts.map +1 -0
  47. package/dist/cloud/api/repos.js +314 -0
  48. package/dist/cloud/api/repos.js.map +1 -0
  49. package/dist/cloud/api/teams.d.ts +7 -0
  50. package/dist/cloud/api/teams.d.ts.map +1 -0
  51. package/dist/cloud/api/teams.js +279 -0
  52. package/dist/cloud/api/teams.js.map +1 -0
  53. package/dist/cloud/api/usage.d.ts +7 -0
  54. package/dist/cloud/api/usage.d.ts.map +1 -0
  55. package/dist/cloud/api/usage.js +98 -0
  56. package/dist/cloud/api/usage.js.map +1 -0
  57. package/dist/cloud/api/workspaces.d.ts +7 -0
  58. package/dist/cloud/api/workspaces.d.ts.map +1 -0
  59. package/dist/cloud/api/workspaces.js +510 -0
  60. package/dist/cloud/api/workspaces.js.map +1 -0
  61. package/dist/cloud/billing/index.d.ts +9 -0
  62. package/dist/cloud/billing/index.d.ts.map +1 -0
  63. package/dist/cloud/billing/index.js +9 -0
  64. package/dist/cloud/billing/index.js.map +1 -0
  65. package/dist/cloud/billing/plans.d.ts +39 -0
  66. package/dist/cloud/billing/plans.d.ts.map +1 -0
  67. package/dist/cloud/billing/plans.js +232 -0
  68. package/dist/cloud/billing/plans.js.map +1 -0
  69. package/dist/cloud/billing/service.d.ts +80 -0
  70. package/dist/cloud/billing/service.d.ts.map +1 -0
  71. package/dist/cloud/billing/service.js +388 -0
  72. package/dist/cloud/billing/service.js.map +1 -0
  73. package/dist/cloud/billing/types.d.ts +135 -0
  74. package/dist/cloud/billing/types.d.ts.map +1 -0
  75. package/dist/cloud/billing/types.js +7 -0
  76. package/dist/cloud/billing/types.js.map +1 -0
  77. package/dist/cloud/config.d.ts +59 -0
  78. package/dist/cloud/config.d.ts.map +1 -0
  79. package/dist/cloud/config.js +83 -0
  80. package/dist/cloud/config.js.map +1 -0
  81. package/dist/cloud/db/drizzle.d.ts +132 -0
  82. package/dist/cloud/db/drizzle.d.ts.map +1 -0
  83. package/dist/cloud/db/drizzle.js +613 -0
  84. package/dist/cloud/db/drizzle.js.map +1 -0
  85. package/dist/cloud/db/index.d.ts +30 -0
  86. package/dist/cloud/db/index.d.ts.map +1 -0
  87. package/dist/cloud/db/index.js +44 -0
  88. package/dist/cloud/db/index.js.map +1 -0
  89. package/dist/cloud/db/schema.d.ts +1792 -0
  90. package/dist/cloud/db/schema.d.ts.map +1 -0
  91. package/dist/cloud/db/schema.js +234 -0
  92. package/dist/cloud/db/schema.js.map +1 -0
  93. package/dist/cloud/index.d.ts +11 -0
  94. package/dist/cloud/index.d.ts.map +1 -0
  95. package/dist/cloud/index.js +37 -0
  96. package/dist/cloud/index.js.map +1 -0
  97. package/dist/cloud/provisioner/index.d.ts +51 -0
  98. package/dist/cloud/provisioner/index.d.ts.map +1 -0
  99. package/dist/cloud/provisioner/index.js +676 -0
  100. package/dist/cloud/provisioner/index.js.map +1 -0
  101. package/dist/cloud/server.d.ts +16 -0
  102. package/dist/cloud/server.d.ts.map +1 -0
  103. package/dist/cloud/server.js +190 -0
  104. package/dist/cloud/server.js.map +1 -0
  105. package/dist/cloud/services/coordinator.d.ts +62 -0
  106. package/dist/cloud/services/coordinator.d.ts.map +1 -0
  107. package/dist/cloud/services/coordinator.js +389 -0
  108. package/dist/cloud/services/coordinator.js.map +1 -0
  109. package/dist/cloud/services/planLimits.d.ts +110 -0
  110. package/dist/cloud/services/planLimits.d.ts.map +1 -0
  111. package/dist/cloud/services/planLimits.js +254 -0
  112. package/dist/cloud/services/planLimits.js.map +1 -0
  113. package/dist/cloud/vault/index.d.ts +76 -0
  114. package/dist/cloud/vault/index.d.ts.map +1 -0
  115. package/dist/cloud/vault/index.js +219 -0
  116. package/dist/cloud/vault/index.js.map +1 -0
  117. package/dist/daemon/agent-manager.d.ts +87 -0
  118. package/dist/daemon/agent-manager.d.ts.map +1 -0
  119. package/dist/daemon/agent-manager.js +412 -0
  120. package/dist/daemon/agent-manager.js.map +1 -0
  121. package/dist/daemon/agent-registry.d.ts +2 -0
  122. package/dist/daemon/agent-registry.d.ts.map +1 -1
  123. package/dist/daemon/agent-registry.js +3 -0
  124. package/dist/daemon/agent-registry.js.map +1 -1
  125. package/dist/daemon/api.d.ts +69 -0
  126. package/dist/daemon/api.d.ts.map +1 -0
  127. package/dist/daemon/api.js +425 -0
  128. package/dist/daemon/api.js.map +1 -0
  129. package/dist/daemon/cloud-sync.d.ts +101 -0
  130. package/dist/daemon/cloud-sync.d.ts.map +1 -0
  131. package/dist/daemon/cloud-sync.js +261 -0
  132. package/dist/daemon/cloud-sync.js.map +1 -0
  133. package/dist/daemon/index.d.ts +4 -0
  134. package/dist/daemon/index.d.ts.map +1 -1
  135. package/dist/daemon/index.js +6 -0
  136. package/dist/daemon/index.js.map +1 -1
  137. package/dist/daemon/orchestrator.d.ts +155 -0
  138. package/dist/daemon/orchestrator.d.ts.map +1 -0
  139. package/dist/daemon/orchestrator.js +736 -0
  140. package/dist/daemon/orchestrator.js.map +1 -0
  141. package/dist/daemon/router.d.ts +24 -0
  142. package/dist/daemon/router.d.ts.map +1 -1
  143. package/dist/daemon/router.js +71 -1
  144. package/dist/daemon/router.js.map +1 -1
  145. package/dist/daemon/server.d.ts +37 -0
  146. package/dist/daemon/server.d.ts.map +1 -1
  147. package/dist/daemon/server.js +191 -16
  148. package/dist/daemon/server.js.map +1 -1
  149. package/dist/daemon/types.d.ts +127 -0
  150. package/dist/daemon/types.d.ts.map +1 -0
  151. package/dist/daemon/types.js +6 -0
  152. package/dist/daemon/types.js.map +1 -0
  153. package/dist/daemon/workspace-manager.d.ts +75 -0
  154. package/dist/daemon/workspace-manager.d.ts.map +1 -0
  155. package/dist/daemon/workspace-manager.js +289 -0
  156. package/dist/daemon/workspace-manager.js.map +1 -0
  157. package/dist/dashboard/out/404.html +1 -1
  158. package/dist/dashboard/out/_next/static/chunks/693-7b3301d8f6bc5014.js +1 -0
  159. package/dist/dashboard/out/_next/static/chunks/713-f78477eb185f1f4d.js +1 -0
  160. package/dist/dashboard/out/_next/static/chunks/766-e53e1cfe39b0b5b5.js +1 -0
  161. package/dist/dashboard/out/_next/static/chunks/900-037c64bfd797fb2a.js +1 -0
  162. package/dist/dashboard/out/_next/static/chunks/app/app/page-e3d9e1f4466b9bae.js +1 -0
  163. package/dist/dashboard/out/_next/static/chunks/app/history/page-b6edd4dde8d08194.js +1 -0
  164. package/dist/dashboard/out/_next/static/chunks/app/layout-2433bb48965f4333.js +1 -0
  165. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-e68825a81db67ba1.js +1 -0
  166. package/dist/dashboard/out/_next/static/chunks/app/page-cc108bf68c8a657f.js +1 -0
  167. package/dist/dashboard/out/_next/static/chunks/app/pricing/page-d80e03a5297f95b6.js +1 -0
  168. package/dist/dashboard/out/_next/static/chunks/main-app-5d692157a8eb1fd9.js +1 -0
  169. package/dist/dashboard/out/_next/static/chunks/{main-e0a1f53fe0617a63.js → main-c2f423b9c9f4591b.js} +1 -1
  170. package/dist/dashboard/out/_next/static/chunks/{webpack-c81f7fd28659d64f.js → webpack-a5acc2831d094776.js} +1 -1
  171. package/dist/dashboard/out/_next/static/css/79b80143647a07d7.css +1 -0
  172. package/dist/dashboard/out/_next/static/css/8cf277370ad48cfe.css +1 -0
  173. package/dist/dashboard/out/alt-logos/agent-relay-logo-128.png +0 -0
  174. package/dist/dashboard/out/alt-logos/agent-relay-logo-256.png +0 -0
  175. package/dist/dashboard/out/alt-logos/agent-relay-logo-32.png +0 -0
  176. package/dist/dashboard/out/alt-logos/agent-relay-logo-512.png +0 -0
  177. package/dist/dashboard/out/alt-logos/agent-relay-logo-64.png +0 -0
  178. package/dist/dashboard/out/alt-logos/agent-relay-logo.svg +45 -0
  179. package/dist/dashboard/out/alt-logos/logo.svg +38 -0
  180. package/dist/dashboard/out/alt-logos/monogram-logo-128.png +0 -0
  181. package/dist/dashboard/out/alt-logos/monogram-logo-256.png +0 -0
  182. package/dist/dashboard/out/alt-logos/monogram-logo-32.png +0 -0
  183. package/dist/dashboard/out/alt-logos/monogram-logo-512.png +0 -0
  184. package/dist/dashboard/out/alt-logos/monogram-logo-64.png +0 -0
  185. package/dist/dashboard/out/alt-logos/monogram-logo.svg +38 -0
  186. package/dist/dashboard/out/app.html +14 -0
  187. package/dist/dashboard/out/app.txt +7 -0
  188. package/dist/dashboard/out/history.html +1 -0
  189. package/dist/dashboard/out/history.txt +7 -0
  190. package/dist/dashboard/out/index.html +1 -1
  191. package/dist/dashboard/out/index.txt +2 -2
  192. package/dist/dashboard/out/metrics.html +1 -515
  193. package/dist/dashboard/out/metrics.txt +2 -2
  194. package/dist/dashboard/out/pricing.html +13 -0
  195. package/dist/dashboard/out/pricing.txt +7 -0
  196. package/dist/dashboard-server/metrics.d.ts.map +1 -1
  197. package/dist/dashboard-server/metrics.js +3 -2
  198. package/dist/dashboard-server/metrics.js.map +1 -1
  199. package/dist/dashboard-server/server.d.ts.map +1 -1
  200. package/dist/dashboard-server/server.js +1279 -56
  201. package/dist/dashboard-server/server.js.map +1 -1
  202. package/dist/protocol/types.d.ts +10 -1
  203. package/dist/protocol/types.d.ts.map +1 -1
  204. package/dist/resiliency/context-persistence.d.ts +140 -0
  205. package/dist/resiliency/context-persistence.d.ts.map +1 -0
  206. package/dist/resiliency/context-persistence.js +397 -0
  207. package/dist/resiliency/context-persistence.js.map +1 -0
  208. package/dist/resiliency/health-monitor.d.ts +97 -0
  209. package/dist/resiliency/health-monitor.d.ts.map +1 -0
  210. package/dist/resiliency/health-monitor.js +291 -0
  211. package/dist/resiliency/health-monitor.js.map +1 -0
  212. package/dist/resiliency/index.d.ts +63 -0
  213. package/dist/resiliency/index.d.ts.map +1 -0
  214. package/dist/resiliency/index.js +63 -0
  215. package/dist/resiliency/index.js.map +1 -0
  216. package/dist/resiliency/logger.d.ts +114 -0
  217. package/dist/resiliency/logger.d.ts.map +1 -0
  218. package/dist/resiliency/logger.js +250 -0
  219. package/dist/resiliency/logger.js.map +1 -0
  220. package/dist/resiliency/metrics.d.ts +115 -0
  221. package/dist/resiliency/metrics.d.ts.map +1 -0
  222. package/dist/resiliency/metrics.js +239 -0
  223. package/dist/resiliency/metrics.js.map +1 -0
  224. package/dist/resiliency/provider-context.d.ts +100 -0
  225. package/dist/resiliency/provider-context.d.ts.map +1 -0
  226. package/dist/resiliency/provider-context.js +360 -0
  227. package/dist/resiliency/provider-context.js.map +1 -0
  228. package/dist/resiliency/supervisor.d.ts +109 -0
  229. package/dist/resiliency/supervisor.d.ts.map +1 -0
  230. package/dist/resiliency/supervisor.js +337 -0
  231. package/dist/resiliency/supervisor.js.map +1 -0
  232. package/dist/storage/adapter.d.ts +2 -0
  233. package/dist/storage/adapter.d.ts.map +1 -1
  234. package/dist/storage/adapter.js +12 -2
  235. package/dist/storage/adapter.js.map +1 -1
  236. package/dist/storage/sqlite-adapter.d.ts.map +1 -1
  237. package/dist/storage/sqlite-adapter.js +18 -14
  238. package/dist/storage/sqlite-adapter.js.map +1 -1
  239. package/dist/utils/index.d.ts +1 -0
  240. package/dist/utils/index.d.ts.map +1 -1
  241. package/dist/utils/index.js +1 -0
  242. package/dist/utils/index.js.map +1 -1
  243. package/dist/utils/logger.d.ts +40 -0
  244. package/dist/utils/logger.d.ts.map +1 -0
  245. package/dist/utils/logger.js +84 -0
  246. package/dist/utils/logger.js.map +1 -0
  247. package/dist/wrapper/client.d.ts +16 -1
  248. package/dist/wrapper/client.d.ts.map +1 -1
  249. package/dist/wrapper/client.js +32 -1
  250. package/dist/wrapper/client.js.map +1 -1
  251. package/dist/wrapper/parser.d.ts +3 -0
  252. package/dist/wrapper/parser.d.ts.map +1 -1
  253. package/dist/wrapper/parser.js +121 -18
  254. package/dist/wrapper/parser.js.map +1 -1
  255. package/dist/wrapper/pty-wrapper.d.ts +28 -1
  256. package/dist/wrapper/pty-wrapper.d.ts.map +1 -1
  257. package/dist/wrapper/pty-wrapper.js +166 -30
  258. package/dist/wrapper/pty-wrapper.js.map +1 -1
  259. package/dist/wrapper/tmux-wrapper.d.ts +5 -0
  260. package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
  261. package/dist/wrapper/tmux-wrapper.js +58 -18
  262. package/dist/wrapper/tmux-wrapper.js.map +1 -1
  263. package/docs/CLOUD-ARCHITECTURE.md +652 -0
  264. package/docs/CLOUD-ONBOARDING-DESIGN.md +1983 -0
  265. package/docs/TESTING_PRESENCE_FEATURES.md +327 -0
  266. package/docs/agent-relay-snippet.md +107 -4
  267. package/docs/guides/CLOUD.md +236 -0
  268. package/docs/guides/LOCAL.md +535 -0
  269. package/docs/guides/SELF-HOSTED.md +494 -0
  270. package/docs/proposals/shadow-as-subagent.md +765 -0
  271. package/docs/proposals/slack-bot-integration.md +1457 -0
  272. package/package.json +33 -4
  273. package/dist/dashboard/out/_next/static/chunks/app/layout-c9d8c5d95e48c6bf.js +0 -1
  274. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-8aa9936bc6c771ab.js +0 -1
  275. package/dist/dashboard/out/_next/static/chunks/app/page-49055e5d2b5e34ec.js +0 -1
  276. package/dist/dashboard/out/_next/static/chunks/main-app-bae2e535de00de50.js +0 -1
  277. package/dist/dashboard/out/_next/static/css/50ed6996e3df7bdd.css +0 -1
  278. /package/dist/dashboard/out/_next/static/{gZXwjIKGDKJ0hiTH-HMeJ → 6HHWb2ZmnJ4OSm0zUP7h4}/_buildManifest.js +0 -0
  279. /package/dist/dashboard/out/_next/static/{gZXwjIKGDKJ0hiTH-HMeJ → 6HHWb2ZmnJ4OSm0zUP7h4}/_ssgManifest.js +0 -0
  280. /package/dist/dashboard/out/_next/static/chunks/{117-3bef7b19f3e60751.js → 117-b2cd8d6485aacf2b.js} +0 -0
  281. /package/dist/dashboard/out/_next/static/chunks/{648-6cf686106c891ad3.js → 648-8f3f26864ce515e5.js} +0 -0
  282. /package/dist/dashboard/out/_next/static/chunks/app/_not-found/{page-8ff6572bc7c9bc61.js → page-0b990dbb71d72a98.js} +0 -0
  283. /package/dist/dashboard/out/_next/static/chunks/{fd9d1056-26bd8d656b496dba.js → fd9d1056-bf46c09eb57e019c.js} +0 -0
@@ -3,6 +3,7 @@ import { WebSocketServer, WebSocket } from 'ws';
3
3
  import http from 'http';
4
4
  import path from 'path';
5
5
  import fs from 'fs';
6
+ import os from 'os';
6
7
  import crypto from 'crypto';
7
8
  import { fileURLToPath } from 'url';
8
9
  import { SqliteStorageAdapter } from '../storage/sqlite-adapter.js';
@@ -11,8 +12,88 @@ import { computeNeedsAttention } from './needs-attention.js';
11
12
  import { computeSystemMetrics, formatPrometheusMetrics } from './metrics.js';
12
13
  import { MultiProjectClient } from '../bridge/multi-project-client.js';
13
14
  import { AgentSpawner } from '../bridge/spawner.js';
15
+ import { loadTeamsConfig } from '../bridge/teams-config.js';
14
16
  const __filename = fileURLToPath(import.meta.url);
15
17
  const __dirname = path.dirname(__filename);
18
+ /**
19
+ * Search for files in a directory matching a query pattern.
20
+ * Uses a simple recursive search with common ignore patterns.
21
+ */
22
+ async function searchFiles(rootDir, query, limit) {
23
+ const results = [];
24
+ const queryLower = query.toLowerCase();
25
+ // Directories to ignore
26
+ const ignoreDirs = new Set([
27
+ 'node_modules', '.git', 'dist', 'build', '.next', 'coverage',
28
+ '__pycache__', '.venv', 'venv', '.cache', '.turbo', '.vercel',
29
+ '.nuxt', '.output', 'vendor', 'target', '.idea', '.vscode'
30
+ ]);
31
+ // File patterns to ignore
32
+ const ignorePatterns = [
33
+ /\.lock$/,
34
+ /\.log$/,
35
+ /\.min\.(js|css)$/,
36
+ /\.map$/,
37
+ /\.d\.ts$/,
38
+ /\.pyc$/,
39
+ ];
40
+ const shouldIgnore = (name, isDir) => {
41
+ if (isDir)
42
+ return ignoreDirs.has(name);
43
+ return ignorePatterns.some(pattern => pattern.test(name));
44
+ };
45
+ const matchesQuery = (filePath, fileName) => {
46
+ if (!query)
47
+ return true;
48
+ const pathLower = filePath.toLowerCase();
49
+ const nameLower = fileName.toLowerCase();
50
+ // If query contains '/', match against full path
51
+ if (queryLower.includes('/')) {
52
+ return pathLower.includes(queryLower);
53
+ }
54
+ // Otherwise match against file name or path segments
55
+ return nameLower.includes(queryLower) || pathLower.includes(queryLower);
56
+ };
57
+ const searchDir = async (dir, relativePath = '') => {
58
+ if (results.length >= limit)
59
+ return;
60
+ try {
61
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
62
+ // Sort: directories first, then alphabetically
63
+ entries.sort((a, b) => {
64
+ if (a.isDirectory() !== b.isDirectory()) {
65
+ return a.isDirectory() ? -1 : 1;
66
+ }
67
+ return a.name.localeCompare(b.name);
68
+ });
69
+ for (const entry of entries) {
70
+ if (results.length >= limit)
71
+ break;
72
+ const entryPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
73
+ const fullPath = path.join(dir, entry.name);
74
+ if (shouldIgnore(entry.name, entry.isDirectory()))
75
+ continue;
76
+ if (matchesQuery(entryPath, entry.name)) {
77
+ results.push({
78
+ path: entryPath,
79
+ name: entry.name,
80
+ isDirectory: entry.isDirectory(),
81
+ });
82
+ }
83
+ // Recurse into directories
84
+ if (entry.isDirectory() && results.length < limit) {
85
+ await searchDir(fullPath, entryPath);
86
+ }
87
+ }
88
+ }
89
+ catch (err) {
90
+ // Ignore permission errors, etc.
91
+ console.warn(`[searchFiles] Error reading ${dir}:`, err);
92
+ }
93
+ };
94
+ await searchDir(rootDir);
95
+ return results;
96
+ }
16
97
  export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPathArg) {
17
98
  // Handle overloaded signatures
18
99
  const options = typeof portOrOptions === 'number'
@@ -51,6 +132,45 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
51
132
  skipUTF8Validation: true,
52
133
  maxPayload: 100 * 1024 * 1024
53
134
  });
135
+ const wssLogs = new WebSocketServer({
136
+ noServer: true,
137
+ perMessageDeflate: false,
138
+ skipUTF8Validation: true,
139
+ maxPayload: 100 * 1024 * 1024
140
+ });
141
+ const wssPresence = new WebSocketServer({
142
+ noServer: true,
143
+ perMessageDeflate: false,
144
+ skipUTF8Validation: true,
145
+ maxPayload: 1024 * 1024 // 1MB - presence messages are small
146
+ });
147
+ // Track log subscriptions: agentName -> Set of WebSocket clients
148
+ const logSubscriptions = new Map();
149
+ const onlineUsers = new Map();
150
+ // Validation helpers for presence
151
+ const isValidUsername = (username) => {
152
+ if (typeof username !== 'string')
153
+ return false;
154
+ // Username should be 1-39 chars, alphanumeric with hyphens (GitHub username rules)
155
+ return /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(username);
156
+ };
157
+ const isValidAvatarUrl = (url) => {
158
+ if (url === undefined || url === null)
159
+ return true;
160
+ if (typeof url !== 'string')
161
+ return false;
162
+ // Must be a valid HTTPS URL from GitHub or similar known providers
163
+ try {
164
+ const parsed = new URL(url);
165
+ return parsed.protocol === 'https:' &&
166
+ (parsed.hostname === 'avatars.githubusercontent.com' ||
167
+ parsed.hostname === 'github.com' ||
168
+ parsed.hostname.endsWith('.githubusercontent.com'));
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ };
54
174
  // Manually handle upgrade requests and route to correct WebSocketServer
55
175
  server.on('upgrade', (request, socket, head) => {
56
176
  const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname;
@@ -64,6 +184,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
64
184
  wssBridge.emit('connection', ws, request);
65
185
  });
66
186
  }
187
+ else if (pathname === '/ws/logs' || pathname.startsWith('/ws/logs/')) {
188
+ wssLogs.handleUpgrade(request, socket, head, (ws) => {
189
+ wssLogs.emit('connection', ws, request);
190
+ });
191
+ }
192
+ else if (pathname === '/ws/presence') {
193
+ wssPresence.handleUpgrade(request, socket, head, (ws) => {
194
+ wssPresence.emit('connection', ws, request);
195
+ });
196
+ }
67
197
  else {
68
198
  // Unknown path - destroy socket
69
199
  socket.destroy();
@@ -76,10 +206,70 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
76
206
  wssBridge.on('error', (err) => {
77
207
  console.error('[dashboard] Bridge WebSocket server error:', err);
78
208
  });
209
+ wssLogs.on('error', (err) => {
210
+ console.error('[dashboard] Logs WebSocket server error:', err);
211
+ });
212
+ wssPresence.on('error', (err) => {
213
+ console.error('[dashboard] Presence WebSocket server error:', err);
214
+ });
79
215
  if (storage) {
80
216
  await storage.init();
81
217
  }
82
- app.use(express.json());
218
+ // Increase JSON body limit for base64 image uploads (10MB)
219
+ app.use(express.json({ limit: '10mb' }));
220
+ // Create attachments directory in user's home directory (~/.relay/attachments)
221
+ // This keeps attachments out of source control while still accessible to agents
222
+ const attachmentsDir = path.join(os.homedir(), '.relay', 'attachments');
223
+ if (!fs.existsSync(attachmentsDir)) {
224
+ fs.mkdirSync(attachmentsDir, { recursive: true });
225
+ }
226
+ // Also keep uploads dir for backwards compatibility (URL-based serving)
227
+ const uploadsDir = path.join(dataDir, 'uploads');
228
+ if (!fs.existsSync(uploadsDir)) {
229
+ fs.mkdirSync(uploadsDir, { recursive: true });
230
+ }
231
+ // Auto-evict old attachments (older than 7 days)
232
+ const ATTACHMENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
233
+ const evictOldAttachments = async () => {
234
+ try {
235
+ const files = await fs.promises.readdir(attachmentsDir);
236
+ const now = Date.now();
237
+ let evictedCount = 0;
238
+ for (const file of files) {
239
+ const filePath = path.join(attachmentsDir, file);
240
+ try {
241
+ const stat = await fs.promises.stat(filePath);
242
+ if (stat.isFile() && (now - stat.mtimeMs) > ATTACHMENT_MAX_AGE_MS) {
243
+ await fs.promises.unlink(filePath);
244
+ evictedCount++;
245
+ }
246
+ }
247
+ catch (err) {
248
+ // Ignore errors for individual files (may have been deleted)
249
+ }
250
+ }
251
+ if (evictedCount > 0) {
252
+ console.log(`[dashboard] Evicted ${evictedCount} old attachment(s)`);
253
+ }
254
+ }
255
+ catch (err) {
256
+ console.error('[dashboard] Failed to evict old attachments:', err);
257
+ }
258
+ };
259
+ // Run eviction on startup and every hour
260
+ evictOldAttachments();
261
+ const evictionInterval = setInterval(evictOldAttachments, 60 * 60 * 1000); // 1 hour
262
+ // Clean up interval on process exit
263
+ process.on('beforeExit', () => {
264
+ clearInterval(evictionInterval);
265
+ });
266
+ // Serve uploaded files statically
267
+ app.use('/uploads', express.static(uploadsDir));
268
+ // Serve attachments from ~/.relay/attachments
269
+ app.use('/attachments', express.static(attachmentsDir));
270
+ // In-memory attachment registry (for current session)
271
+ // Attachments are also stored on disk, so this is just for quick lookups
272
+ const attachmentRegistry = new Map();
83
273
  // Serve dashboard static files at root (built with `next build` in src/dashboard)
84
274
  // __dirname is dist/dashboard-server, dashboard is at ../dashboard/out (relative to dist)
85
275
  // But in source it's at ../dashboard/out (relative to src/dashboard-server)
@@ -99,39 +289,70 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
99
289
  else {
100
290
  console.error('[dashboard] Dashboard not found at:', dashboardDistDir, 'or', dashboardSourceDir);
101
291
  }
102
- // Relay client for sending messages from dashboard
292
+ // Relay clients for sending messages from dashboard
293
+ // Map of senderName -> RelayClient for per-user connections
103
294
  const socketPath = path.join(dataDir, 'relay.sock');
104
- let relayClient;
105
- const connectRelayClient = async () => {
295
+ const relayClients = new Map();
296
+ // Track pending client connections to prevent race conditions
297
+ const pendingConnections = new Map();
298
+ // Get or create a relay client for a specific sender
299
+ const getRelayClient = async (senderName = 'Dashboard') => {
300
+ // Check if we already have a connected client for this sender
301
+ const existing = relayClients.get(senderName);
302
+ if (existing && existing.state === 'READY') {
303
+ return existing;
304
+ }
305
+ // Check if there's already a pending connection for this sender
306
+ const pending = pendingConnections.get(senderName);
307
+ if (pending) {
308
+ return pending;
309
+ }
106
310
  // Only attempt connection if socket exists (daemon is running)
107
311
  if (!fs.existsSync(socketPath)) {
108
312
  console.log('[dashboard] Relay socket not found, messaging disabled');
109
- return;
110
- }
111
- relayClient = new RelayClient({
112
- socketPath,
113
- agentName: 'Dashboard',
114
- cli: 'dashboard',
115
- reconnect: true,
116
- maxReconnectAttempts: 5,
117
- });
118
- relayClient.onError = (err) => {
119
- console.error('[dashboard] Relay client error:', err.message);
120
- };
121
- relayClient.onStateChange = (state) => {
122
- console.log(`[dashboard] Relay client state: ${state}`);
123
- };
124
- try {
125
- await relayClient.connect();
126
- console.log('[dashboard] Connected to relay daemon');
127
- }
128
- catch (err) {
129
- console.error('[dashboard] Failed to connect to relay daemon:', err);
130
- relayClient = undefined;
313
+ return undefined;
131
314
  }
315
+ // Create connection promise to prevent race conditions
316
+ const connectionPromise = (async () => {
317
+ // Create new client for this sender
318
+ const client = new RelayClient({
319
+ socketPath,
320
+ agentName: senderName,
321
+ cli: 'dashboard',
322
+ reconnect: true,
323
+ maxReconnectAttempts: 5,
324
+ });
325
+ client.onError = (err) => {
326
+ console.error(`[dashboard] Relay client error for ${senderName}:`, err.message);
327
+ };
328
+ client.onStateChange = (state) => {
329
+ console.log(`[dashboard] Relay client for ${senderName} state: ${state}`);
330
+ // Clean up disconnected clients
331
+ if (state === 'DISCONNECTED') {
332
+ relayClients.delete(senderName);
333
+ }
334
+ };
335
+ try {
336
+ await client.connect();
337
+ relayClients.set(senderName, client);
338
+ console.log(`[dashboard] Connected to relay daemon as ${senderName}`);
339
+ return client;
340
+ }
341
+ catch (err) {
342
+ console.error(`[dashboard] Failed to connect to relay daemon as ${senderName}:`, err);
343
+ return undefined;
344
+ }
345
+ finally {
346
+ // Clean up pending connection
347
+ pendingConnections.delete(senderName);
348
+ }
349
+ })();
350
+ // Store the pending connection
351
+ pendingConnections.set(senderName, connectionPromise);
352
+ return connectionPromise;
132
353
  };
133
- // Start relay client connection (non-blocking)
134
- connectRelayClient().catch(() => { });
354
+ // Start default relay client connection (non-blocking)
355
+ getRelayClient('Dashboard').catch(() => { });
135
356
  // Bridge client for cross-project messaging
136
357
  let bridgeClient;
137
358
  let bridgeClientConnecting = false;
@@ -188,26 +409,132 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
188
409
  };
189
410
  // Start bridge client connection (non-blocking)
190
411
  connectBridgeClient().catch(() => { });
412
+ // Helper to check if an agent is online (seen within heartbeat timeout window)
413
+ // Uses 30 second threshold to align with heartbeat timeout (5s * 6 multiplier)
414
+ const isAgentOnline = (agentName) => {
415
+ if (agentName === '*')
416
+ return true; // Broadcast always allowed
417
+ const agentsPath = path.join(teamDir, 'agents.json');
418
+ if (!fs.existsSync(agentsPath))
419
+ return false;
420
+ try {
421
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
422
+ const agent = data.agents?.find((a) => a.name === agentName);
423
+ if (!agent || !agent.lastSeen)
424
+ return false;
425
+ const thirtySecondsAgo = Date.now() - 30 * 1000;
426
+ return new Date(agent.lastSeen).getTime() > thirtySecondsAgo;
427
+ }
428
+ catch {
429
+ return false;
430
+ }
431
+ };
432
+ // Helper to get team members from teams.json, agents.json, and spawner's active workers
433
+ const getTeamMembers = (teamName) => {
434
+ const members = new Set();
435
+ // Check teams.json first - this is the source of truth for team definitions
436
+ const teamsConfig = loadTeamsConfig(projectRoot || dataDir);
437
+ if (teamsConfig && teamsConfig.team === teamName) {
438
+ for (const agent of teamsConfig.agents) {
439
+ members.add(agent.name);
440
+ }
441
+ }
442
+ // Check spawner's active workers (they have accurate team info for spawned agents)
443
+ if (spawner) {
444
+ const activeWorkers = spawner.getActiveWorkers();
445
+ for (const worker of activeWorkers) {
446
+ if (worker.team === teamName) {
447
+ members.add(worker.name);
448
+ }
449
+ }
450
+ }
451
+ // Also check agents.json for persisted team info
452
+ const agentsPath = path.join(teamDir, 'agents.json');
453
+ if (fs.existsSync(agentsPath)) {
454
+ try {
455
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
456
+ for (const agent of (data.agents || [])) {
457
+ if (agent.team === teamName) {
458
+ members.add(agent.name);
459
+ }
460
+ }
461
+ }
462
+ catch {
463
+ // Ignore parse errors
464
+ }
465
+ }
466
+ return Array.from(members);
467
+ };
191
468
  // API endpoint to send messages
192
469
  app.post('/api/send', async (req, res) => {
193
- const { to, message, thread } = req.body;
470
+ const { to, message, thread, attachments: attachmentIds, from: senderName } = req.body;
194
471
  if (!to || !message) {
195
472
  return res.status(400).json({ error: 'Missing "to" or "message" field' });
196
473
  }
197
- if (!relayClient || relayClient.state !== 'READY') {
198
- // Try to reconnect
199
- await connectRelayClient();
200
- if (!relayClient || relayClient.state !== 'READY') {
201
- return res.status(503).json({ error: 'Relay daemon not connected' });
474
+ // Check if this is a team mention (team:teamName)
475
+ const teamMatch = to.match(/^team:(.+)$/);
476
+ let targets;
477
+ if (teamMatch) {
478
+ const teamName = teamMatch[1];
479
+ const members = getTeamMembers(teamName);
480
+ if (members.length === 0) {
481
+ return res.status(404).json({ error: `No agents found in team "${teamName}"` });
482
+ }
483
+ // Filter to only online members
484
+ targets = members.filter(isAgentOnline);
485
+ if (targets.length === 0) {
486
+ return res.status(404).json({ error: `No online agents in team "${teamName}"` });
202
487
  }
203
488
  }
489
+ else {
490
+ // Fail fast if target agent is offline (except broadcasts)
491
+ if (to !== '*' && !isAgentOnline(to)) {
492
+ return res.status(404).json({ error: `Agent "${to}" is not online` });
493
+ }
494
+ targets = [to];
495
+ }
496
+ // Get or create relay client for this sender (defaults to 'Dashboard' for non-cloud mode)
497
+ const relayClient = await getRelayClient(senderName || 'Dashboard');
498
+ if (!relayClient || relayClient.state !== 'READY') {
499
+ return res.status(503).json({ error: 'Relay daemon not connected' });
500
+ }
204
501
  try {
205
- const sent = relayClient.sendMessage(to, message, 'message', undefined, thread);
206
- if (sent) {
207
- res.json({ success: true });
502
+ // Resolve attachments if provided
503
+ let attachments;
504
+ if (attachmentIds && Array.isArray(attachmentIds) && attachmentIds.length > 0) {
505
+ attachments = [];
506
+ for (const id of attachmentIds) {
507
+ const attachment = attachmentRegistry.get(id);
508
+ if (attachment) {
509
+ attachments.push(attachment);
510
+ }
511
+ }
512
+ }
513
+ // Include attachments and channel context in the message data field
514
+ // For broadcasts (to='*'), include channel: 'general' so replies can be routed back
515
+ const isBroadcast = targets.length === 1 && targets[0] === '*';
516
+ const messageData = {};
517
+ if (attachments && attachments.length > 0) {
518
+ messageData.attachments = attachments;
519
+ }
520
+ if (isBroadcast) {
521
+ messageData.channel = 'general';
522
+ }
523
+ const hasMessageData = Object.keys(messageData).length > 0;
524
+ // Send to all targets (single agent, team members, or broadcast)
525
+ let allSent = true;
526
+ for (const target of targets) {
527
+ const sent = relayClient.sendMessage(target, message, 'message', hasMessageData ? messageData : undefined, thread);
528
+ if (!sent) {
529
+ allSent = false;
530
+ console.error(`[dashboard] Failed to send message to ${target}`);
531
+ }
532
+ }
533
+ if (allSent) {
534
+ res.json({ success: true, sentTo: targets.length > 1 ? targets : targets[0] });
208
535
  }
209
536
  else {
210
- res.status(500).json({ error: 'Failed to send message' });
537
+ res.status(500).json({ error: 'Failed to send message to some recipients' });
211
538
  }
212
539
  }
213
540
  catch (err) {
@@ -242,6 +569,91 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
242
569
  res.status(500).json({ error: 'Failed to send bridge message' });
243
570
  }
244
571
  });
572
+ // API endpoint to upload attachments (images/screenshots)
573
+ app.post('/api/upload', async (req, res) => {
574
+ const { filename, mimeType, data } = req.body;
575
+ // Validate required fields
576
+ if (!filename || !mimeType || !data) {
577
+ return res.status(400).json({
578
+ success: false,
579
+ error: 'Missing required fields: filename, mimeType, data',
580
+ });
581
+ }
582
+ // Validate mime type (only allow images for now)
583
+ const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
584
+ if (!allowedTypes.includes(mimeType)) {
585
+ return res.status(400).json({
586
+ success: false,
587
+ error: `Invalid file type. Allowed types: ${allowedTypes.join(', ')}`,
588
+ });
589
+ }
590
+ try {
591
+ // Decode base64 data
592
+ const base64Data = data.replace(/^data:[^;]+;base64,/, '');
593
+ const buffer = Buffer.from(base64Data, 'base64');
594
+ // Generate unique ID and filename for the attachment
595
+ const attachmentId = crypto.randomUUID();
596
+ const timestamp = Date.now();
597
+ const ext = mimeType.split('/')[1].replace('svg+xml', 'svg');
598
+ // Use format: {messageId}-{timestamp}.{ext} for unique, identifiable filenames
599
+ const safeFilename = `${attachmentId.substring(0, 8)}-${timestamp}.${ext}`;
600
+ // Save to ~/.relay/attachments/ directory for agents to access
601
+ const attachmentFilePath = path.join(attachmentsDir, safeFilename);
602
+ fs.writeFileSync(attachmentFilePath, buffer);
603
+ // Create attachment record with file path for agents
604
+ const attachment = {
605
+ id: attachmentId,
606
+ filename: filename,
607
+ mimeType: mimeType,
608
+ size: buffer.length,
609
+ url: `/attachments/${safeFilename}`,
610
+ // Include absolute file path so agents can read the file directly
611
+ filePath: attachmentFilePath,
612
+ // Include base64 data for agents that can't access the file
613
+ data: data,
614
+ };
615
+ // Store in registry for lookup when sending messages
616
+ attachmentRegistry.set(attachmentId, attachment);
617
+ console.log(`[dashboard] Uploaded attachment: ${filename} (${buffer.length} bytes) -> ${attachmentFilePath}`);
618
+ res.json({
619
+ success: true,
620
+ attachment: {
621
+ id: attachment.id,
622
+ filename: attachment.filename,
623
+ mimeType: attachment.mimeType,
624
+ size: attachment.size,
625
+ url: attachment.url,
626
+ filePath: attachment.filePath,
627
+ },
628
+ });
629
+ }
630
+ catch (err) {
631
+ console.error('[dashboard] Upload failed:', err);
632
+ res.status(500).json({
633
+ success: false,
634
+ error: 'Failed to upload file',
635
+ });
636
+ }
637
+ });
638
+ // API endpoint to get attachment by ID
639
+ app.get('/api/attachment/:id', (req, res) => {
640
+ const { id } = req.params;
641
+ const attachment = attachmentRegistry.get(id);
642
+ if (!attachment) {
643
+ return res.status(404).json({ error: 'Attachment not found' });
644
+ }
645
+ res.json({
646
+ success: true,
647
+ attachment: {
648
+ id: attachment.id,
649
+ filename: attachment.filename,
650
+ mimeType: attachment.mimeType,
651
+ size: attachment.size,
652
+ url: attachment.url,
653
+ filePath: attachment.filePath,
654
+ },
655
+ });
656
+ });
245
657
  const getTeamData = () => {
246
658
  // Try team.json first (file-based team mode)
247
659
  const teamPath = path.join(teamDir, 'team.json');
@@ -266,6 +678,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
266
678
  cli: a.cli ?? 'Unknown',
267
679
  lastSeen: a.lastSeen ?? a.connectedAt,
268
680
  lastActive: a.lastSeen ?? a.connectedAt,
681
+ team: a.team,
269
682
  })),
270
683
  };
271
684
  }
@@ -315,16 +728,40 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
315
728
  return [];
316
729
  }
317
730
  };
731
+ // Helper to check if an agent name is internal/system (should be hidden from UI)
732
+ // Convention: agent names starting with __ are internal (e.g., __spawner__, __DashboardBridge__)
733
+ const isInternalAgent = (name) => {
734
+ return name.startsWith('__');
735
+ };
318
736
  const mapStoredMessages = (rows) => rows
319
- .map((row) => ({
320
- from: row.from,
321
- to: row.to,
322
- content: row.body,
323
- timestamp: new Date(row.ts).toISOString(),
324
- id: row.id,
325
- thread: row.thread,
326
- isBroadcast: row.is_broadcast,
327
- }));
737
+ // Filter out messages from/to internal system agents (e.g., __spawner__)
738
+ .filter((row) => !isInternalAgent(row.from) && !isInternalAgent(row.to))
739
+ .map((row) => {
740
+ // Extract attachments and channel from the data field if present
741
+ let attachments;
742
+ let channel;
743
+ if (row.data && typeof row.data === 'object') {
744
+ if ('attachments' in row.data) {
745
+ attachments = row.data.attachments;
746
+ }
747
+ if ('channel' in row.data) {
748
+ channel = row.data.channel;
749
+ }
750
+ }
751
+ return {
752
+ from: row.from,
753
+ to: row.to,
754
+ content: row.body,
755
+ timestamp: new Date(row.ts).toISOString(),
756
+ id: row.id,
757
+ thread: row.thread,
758
+ isBroadcast: row.is_broadcast,
759
+ replyCount: row.replyCount,
760
+ status: row.status,
761
+ attachments,
762
+ channel,
763
+ };
764
+ });
328
765
  const getMessages = async (agents) => {
329
766
  if (storage) {
330
767
  const rows = await storage.getMessages({ limit: 100, order: 'desc' });
@@ -397,6 +834,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
397
834
  lastSeen: a.lastSeen,
398
835
  lastActive: a.lastActive,
399
836
  needsAttention: false,
837
+ team: a.team,
400
838
  });
401
839
  });
402
840
  // Update inbox counts if fallback mode; if storage, count messages addressed to agent
@@ -443,7 +881,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
443
881
  }
444
882
  });
445
883
  // Read processing state from daemon
446
- const processingStatePath = path.join(dataDir, 'processing-state.json');
884
+ const processingStatePath = path.join(teamDir, 'processing-state.json');
447
885
  if (fs.existsSync(processingStatePath)) {
448
886
  try {
449
887
  const processingData = JSON.parse(fs.readFileSync(processingStatePath, 'utf-8'));
@@ -473,6 +911,18 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
473
911
  }
474
912
  }
475
913
  }
914
+ // Set team from teams.json for agents that don't have a team yet
915
+ // This ensures agents defined in teams.json are associated with their team
916
+ // even if they weren't spawned via auto-spawn
917
+ const teamsConfig = loadTeamsConfig(projectRoot || dataDir);
918
+ if (teamsConfig) {
919
+ for (const teamAgent of teamsConfig.agents) {
920
+ const agent = agentsMap.get(teamAgent.name);
921
+ if (agent && !agent.team) {
922
+ agent.team = teamsConfig.team;
923
+ }
924
+ }
925
+ }
476
926
  // Fetch sessions and summaries in parallel
477
927
  const [sessions, summaries] = await Promise.all([
478
928
  getRecentSessions(),
@@ -480,9 +930,11 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
480
930
  ]);
481
931
  // Filter agents:
482
932
  // 1. Exclude "Dashboard" (internal agent, not a real team member)
483
- // 2. Exclude offline agents (no lastSeen or lastSeen > 5 minutes ago)
933
+ // 2. Exclude offline agents (no lastSeen or lastSeen > threshold)
484
934
  const now = Date.now();
485
- const OFFLINE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
935
+ // 30 seconds - aligns with heartbeat timeout (5s heartbeat * 6 multiplier = 30s)
936
+ // This ensures agents disappear quickly after they stop responding to heartbeats
937
+ const OFFLINE_THRESHOLD_MS = 30 * 1000;
486
938
  const filteredAgents = Array.from(agentsMap.values()).filter(agent => {
487
939
  // Exclude Dashboard
488
940
  if (agent.name === 'Dashboard')
@@ -558,13 +1010,13 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
558
1010
  try {
559
1011
  const agentsData = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
560
1012
  if (agentsData.agents && Array.isArray(agentsData.agents)) {
561
- // Filter to only show online agents (seen in last 5 minutes)
562
- const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
1013
+ // Filter to only show online agents (seen within 30 seconds - aligns with heartbeat timeout)
1014
+ const thirtySecondsAgo = Date.now() - 30 * 1000;
563
1015
  project.agents = agentsData.agents
564
1016
  .filter((a) => {
565
1017
  if (!a.lastSeen)
566
1018
  return false;
567
- return new Date(a.lastSeen).getTime() > fiveMinutesAgo;
1019
+ return new Date(a.lastSeen).getTime() > thirtySecondsAgo;
568
1020
  })
569
1021
  .map((a) => ({
570
1022
  name: a.name,
@@ -674,12 +1126,375 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
674
1126
  console.log('[dashboard] Bridge WebSocket client disconnected, code:', code, 'reason:', reason?.toString() || 'none');
675
1127
  });
676
1128
  });
1129
+ // Track alive status for ping/pong keepalive on log connections
1130
+ const logClientAlive = new WeakMap();
1131
+ // Ping interval for log WebSocket connections (30 seconds)
1132
+ // This prevents TCP/proxy timeouts from killing idle connections
1133
+ const LOG_PING_INTERVAL_MS = 30000;
1134
+ const logPingInterval = setInterval(() => {
1135
+ wssLogs.clients.forEach((ws) => {
1136
+ if (logClientAlive.get(ws) === false) {
1137
+ // Client didn't respond to last ping - close gracefully
1138
+ console.log('[dashboard] Logs WebSocket client unresponsive, closing gracefully');
1139
+ ws.close(1000, 'unresponsive');
1140
+ return;
1141
+ }
1142
+ // Mark as not alive until we get a pong
1143
+ logClientAlive.set(ws, false);
1144
+ ws.ping();
1145
+ });
1146
+ }, LOG_PING_INTERVAL_MS);
1147
+ // Clean up ping interval on server close
1148
+ wssLogs.on('close', () => {
1149
+ clearInterval(logPingInterval);
1150
+ });
1151
+ // Handle logs WebSocket connections for live log streaming
1152
+ wssLogs.on('connection', (ws, req) => {
1153
+ console.log('[dashboard] Logs WebSocket client connected');
1154
+ const clientSubscriptions = new Set();
1155
+ // Mark client as alive initially
1156
+ logClientAlive.set(ws, true);
1157
+ // Handle pong responses (keep connection alive)
1158
+ ws.on('pong', () => {
1159
+ logClientAlive.set(ws, true);
1160
+ });
1161
+ // Helper to check if agent is daemon-connected (from agents.json)
1162
+ const isDaemonConnected = (agentName) => {
1163
+ const agentsPath = path.join(teamDir, 'agents.json');
1164
+ if (!fs.existsSync(agentsPath))
1165
+ return false;
1166
+ try {
1167
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
1168
+ return data.agents?.some((a) => a.name === agentName) ?? false;
1169
+ }
1170
+ catch {
1171
+ return false;
1172
+ }
1173
+ };
1174
+ // Helper to subscribe to an agent
1175
+ const subscribeToAgent = (agentName) => {
1176
+ const isSpawned = spawner?.hasWorker(agentName) ?? false;
1177
+ const isDaemon = isDaemonConnected(agentName);
1178
+ // Check if agent exists (either spawned or daemon-connected)
1179
+ if (!isSpawned && !isDaemon) {
1180
+ ws.send(JSON.stringify({
1181
+ type: 'error',
1182
+ agent: agentName,
1183
+ error: `Agent ${agentName} not found`,
1184
+ }));
1185
+ // Close with custom code 4404 to signal "agent not found" - client should not reconnect
1186
+ ws.close(4404, 'Agent not found');
1187
+ return false;
1188
+ }
1189
+ // Add to subscriptions
1190
+ clientSubscriptions.add(agentName);
1191
+ if (!logSubscriptions.has(agentName)) {
1192
+ logSubscriptions.set(agentName, new Set());
1193
+ }
1194
+ logSubscriptions.get(agentName).add(ws);
1195
+ console.log(`[dashboard] Client subscribed to logs for: ${agentName} (spawned: ${isSpawned}, daemon: ${isDaemon})`);
1196
+ if (isSpawned && spawner) {
1197
+ // Send initial log history for spawned agents
1198
+ const lines = spawner.getWorkerOutput(agentName, 200);
1199
+ ws.send(JSON.stringify({
1200
+ type: 'history',
1201
+ agent: agentName,
1202
+ lines: lines || [],
1203
+ }));
1204
+ }
1205
+ else {
1206
+ // For daemon-connected agents, explain that PTY output isn't available
1207
+ ws.send(JSON.stringify({
1208
+ type: 'history',
1209
+ agent: agentName,
1210
+ lines: [`[${agentName} is a daemon-connected agent - PTY output not available. Showing relay messages only.]`],
1211
+ }));
1212
+ }
1213
+ ws.send(JSON.stringify({
1214
+ type: 'subscribed',
1215
+ agent: agentName,
1216
+ }));
1217
+ return true;
1218
+ };
1219
+ // Check if agent name is in URL path: /ws/logs/:agentName
1220
+ const pathname = new URL(req.url || '', `http://${req.headers.host}`).pathname;
1221
+ const pathMatch = pathname.match(/^\/ws\/logs\/(.+)$/);
1222
+ if (pathMatch) {
1223
+ const agentName = decodeURIComponent(pathMatch[1]);
1224
+ subscribeToAgent(agentName);
1225
+ }
1226
+ ws.on('message', (data) => {
1227
+ try {
1228
+ const msg = JSON.parse(data.toString());
1229
+ // Subscribe to agent logs
1230
+ if (msg.subscribe && typeof msg.subscribe === 'string') {
1231
+ subscribeToAgent(msg.subscribe);
1232
+ }
1233
+ // Unsubscribe from agent logs
1234
+ if (msg.unsubscribe && typeof msg.unsubscribe === 'string') {
1235
+ const agentName = msg.unsubscribe;
1236
+ clientSubscriptions.delete(agentName);
1237
+ logSubscriptions.get(agentName)?.delete(ws);
1238
+ console.log(`[dashboard] Client unsubscribed from logs for: ${agentName}`);
1239
+ ws.send(JSON.stringify({
1240
+ type: 'unsubscribed',
1241
+ agent: agentName,
1242
+ }));
1243
+ }
1244
+ }
1245
+ catch (err) {
1246
+ console.error('[dashboard] Invalid logs WebSocket message:', err);
1247
+ }
1248
+ });
1249
+ ws.on('error', (err) => {
1250
+ console.error('[dashboard] Logs WebSocket client error:', err);
1251
+ });
1252
+ ws.on('close', (code, reason) => {
1253
+ // Clean up subscriptions on disconnect
1254
+ for (const agentName of clientSubscriptions) {
1255
+ logSubscriptions.get(agentName)?.delete(ws);
1256
+ }
1257
+ const reasonStr = reason?.toString() || 'no reason';
1258
+ console.log(`[dashboard] Logs WebSocket client disconnected (code: ${code}, reason: ${reasonStr})`);
1259
+ });
1260
+ });
1261
+ // Function to broadcast log output to subscribed clients
1262
+ const broadcastLogOutput = (agentName, output) => {
1263
+ const clients = logSubscriptions.get(agentName);
1264
+ if (!clients || clients.size === 0)
1265
+ return;
1266
+ const payload = JSON.stringify({
1267
+ type: 'output',
1268
+ agent: agentName,
1269
+ data: output,
1270
+ timestamp: new Date().toISOString(),
1271
+ });
1272
+ for (const client of clients) {
1273
+ if (client.readyState === WebSocket.OPEN) {
1274
+ client.send(payload);
1275
+ }
1276
+ }
1277
+ };
1278
+ // Expose broadcastLogOutput for PTY wrappers to call
1279
+ global.__broadcastLogOutput = broadcastLogOutput;
1280
+ // ===== Presence WebSocket Handler =====
1281
+ // Helper to broadcast to all presence clients
1282
+ const broadcastPresence = (message, exclude) => {
1283
+ const payload = JSON.stringify(message);
1284
+ wssPresence.clients.forEach((client) => {
1285
+ if (client !== exclude && client.readyState === WebSocket.OPEN) {
1286
+ client.send(payload);
1287
+ }
1288
+ });
1289
+ };
1290
+ // Helper to get online users list (without ws references)
1291
+ const getOnlineUsersList = () => {
1292
+ return Array.from(onlineUsers.values()).map((state) => state.info);
1293
+ };
1294
+ wssPresence.on('connection', (ws) => {
1295
+ console.log('[dashboard] Presence WebSocket client connected');
1296
+ let clientUsername;
1297
+ ws.on('message', (data) => {
1298
+ try {
1299
+ const msg = JSON.parse(data.toString());
1300
+ if (msg.type === 'presence') {
1301
+ if (msg.action === 'join' && msg.user?.username) {
1302
+ const username = msg.user.username;
1303
+ const avatarUrl = msg.user.avatarUrl;
1304
+ // Validate inputs
1305
+ if (!isValidUsername(username)) {
1306
+ console.warn(`[dashboard] Invalid username rejected: ${username}`);
1307
+ return;
1308
+ }
1309
+ if (!isValidAvatarUrl(avatarUrl)) {
1310
+ console.warn(`[dashboard] Invalid avatar URL rejected for user ${username}`);
1311
+ return;
1312
+ }
1313
+ clientUsername = username;
1314
+ const now = new Date().toISOString();
1315
+ // Check if user already has connections (multi-tab support)
1316
+ const existing = onlineUsers.get(username);
1317
+ if (existing) {
1318
+ // Add this connection to existing user
1319
+ existing.connections.add(ws);
1320
+ existing.info.lastSeen = now;
1321
+ console.log(`[dashboard] User ${username} opened new tab (${existing.connections.size} connections)`);
1322
+ }
1323
+ else {
1324
+ // New user - create presence state
1325
+ onlineUsers.set(username, {
1326
+ info: {
1327
+ username,
1328
+ avatarUrl,
1329
+ connectedAt: now,
1330
+ lastSeen: now,
1331
+ },
1332
+ connections: new Set([ws]),
1333
+ });
1334
+ console.log(`[dashboard] User ${username} came online`);
1335
+ // Broadcast join to all other clients (only for truly new users)
1336
+ broadcastPresence({
1337
+ type: 'presence_join',
1338
+ user: {
1339
+ username,
1340
+ avatarUrl,
1341
+ connectedAt: now,
1342
+ lastSeen: now,
1343
+ },
1344
+ }, ws);
1345
+ }
1346
+ // Send current online users list to the new client
1347
+ ws.send(JSON.stringify({
1348
+ type: 'presence_list',
1349
+ users: getOnlineUsersList(),
1350
+ }));
1351
+ }
1352
+ else if (msg.action === 'leave') {
1353
+ // Security: Only allow leaving your own username
1354
+ // Must have authenticated first
1355
+ if (!clientUsername) {
1356
+ console.warn(`[dashboard] Security: Unauthenticated leave attempt`);
1357
+ return;
1358
+ }
1359
+ if (msg.username !== clientUsername) {
1360
+ console.warn(`[dashboard] Security: User ${clientUsername} tried to remove ${msg.username}`);
1361
+ return;
1362
+ }
1363
+ // Remove this connection from the user's set
1364
+ const username = clientUsername; // Narrow type for TypeScript
1365
+ const userState = onlineUsers.get(username);
1366
+ if (userState) {
1367
+ userState.connections.delete(ws);
1368
+ // Only broadcast leave if no more connections
1369
+ if (userState.connections.size === 0) {
1370
+ onlineUsers.delete(username);
1371
+ console.log(`[dashboard] User ${username} went offline`);
1372
+ broadcastPresence({
1373
+ type: 'presence_leave',
1374
+ username,
1375
+ });
1376
+ }
1377
+ else {
1378
+ console.log(`[dashboard] User ${username} closed tab (${userState.connections.size} remaining)`);
1379
+ }
1380
+ }
1381
+ }
1382
+ }
1383
+ else if (msg.type === 'typing') {
1384
+ // Must have authenticated first
1385
+ if (!clientUsername) {
1386
+ console.warn(`[dashboard] Security: Unauthenticated typing attempt`);
1387
+ return;
1388
+ }
1389
+ // Validate typing message comes from authenticated user
1390
+ if (msg.username !== clientUsername) {
1391
+ console.warn(`[dashboard] Security: Typing message username mismatch`);
1392
+ return;
1393
+ }
1394
+ // Update last seen
1395
+ const username = clientUsername; // Narrow type for TypeScript
1396
+ const userState = onlineUsers.get(username);
1397
+ if (userState) {
1398
+ userState.info.lastSeen = new Date().toISOString();
1399
+ }
1400
+ // Broadcast typing indicator to all other clients
1401
+ broadcastPresence({
1402
+ type: 'typing',
1403
+ username,
1404
+ avatarUrl: userState?.info.avatarUrl,
1405
+ isTyping: msg.isTyping,
1406
+ }, ws);
1407
+ }
1408
+ }
1409
+ catch (err) {
1410
+ console.error('[dashboard] Invalid presence message:', err);
1411
+ }
1412
+ });
1413
+ ws.on('error', (err) => {
1414
+ console.error('[dashboard] Presence WebSocket client error:', err);
1415
+ });
1416
+ ws.on('close', () => {
1417
+ // Clean up on disconnect with multi-tab support
1418
+ if (clientUsername) {
1419
+ const userState = onlineUsers.get(clientUsername);
1420
+ if (userState) {
1421
+ userState.connections.delete(ws);
1422
+ // Only broadcast leave if no more connections
1423
+ if (userState.connections.size === 0) {
1424
+ onlineUsers.delete(clientUsername);
1425
+ console.log(`[dashboard] User ${clientUsername} disconnected`);
1426
+ broadcastPresence({
1427
+ type: 'presence_leave',
1428
+ username: clientUsername,
1429
+ });
1430
+ }
1431
+ else {
1432
+ console.log(`[dashboard] User ${clientUsername} closed connection (${userState.connections.size} remaining)`);
1433
+ }
1434
+ }
1435
+ }
1436
+ });
1437
+ });
677
1438
  app.get('/api/data', (req, res) => {
678
1439
  getAllData().then((data) => res.json(data)).catch((err) => {
679
1440
  console.error('Failed to fetch dashboard data', err);
680
1441
  res.status(500).json({ error: 'Failed to load data' });
681
1442
  });
682
1443
  });
1444
+ // ===== Health Check API =====
1445
+ /**
1446
+ * GET /health - Health check endpoint for monitoring
1447
+ * Returns 200 if the daemon is healthy
1448
+ */
1449
+ app.get('/health', async (req, res) => {
1450
+ const uptime = process.uptime();
1451
+ const memUsage = process.memoryUsage();
1452
+ const socketExists = fs.existsSync(socketPath);
1453
+ // Check relay client connectivity (check if default Dashboard client is connected)
1454
+ const defaultClient = relayClients.get('Dashboard');
1455
+ const relayConnected = defaultClient?.state === 'READY';
1456
+ // If socket doesn't exist, daemon may not be running properly
1457
+ if (!socketExists) {
1458
+ return res.status(503).json({
1459
+ status: 'unhealthy',
1460
+ reason: 'Relay socket not found',
1461
+ uptime,
1462
+ memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
1463
+ });
1464
+ }
1465
+ res.json({
1466
+ status: 'healthy',
1467
+ uptime,
1468
+ memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
1469
+ relayConnected,
1470
+ websocketClients: wss.clients.size,
1471
+ });
1472
+ });
1473
+ /**
1474
+ * GET /api/health - Alternative health endpoint (same as /health)
1475
+ */
1476
+ app.get('/api/health', async (req, res) => {
1477
+ const uptime = process.uptime();
1478
+ const memUsage = process.memoryUsage();
1479
+ const socketExists = fs.existsSync(socketPath);
1480
+ const defaultClient = relayClients.get('Dashboard');
1481
+ const relayConnected = defaultClient?.state === 'READY';
1482
+ if (!socketExists) {
1483
+ return res.status(503).json({
1484
+ status: 'unhealthy',
1485
+ reason: 'Relay socket not found',
1486
+ uptime,
1487
+ memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
1488
+ });
1489
+ }
1490
+ res.json({
1491
+ status: 'healthy',
1492
+ uptime,
1493
+ memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
1494
+ relayConnected,
1495
+ websocketClients: wss.clients.size,
1496
+ });
1497
+ });
683
1498
  // ===== Metrics API =====
684
1499
  /**
685
1500
  * GET /api/metrics - JSON format metrics for dashboard
@@ -749,6 +1564,30 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
749
1564
  res.status(500).send('# Error computing metrics\n');
750
1565
  }
751
1566
  });
1567
+ // ===== File Search API =====
1568
+ /**
1569
+ * GET /api/files - Search for files in the repository
1570
+ * Query params:
1571
+ * - q: Search query (file path pattern)
1572
+ * - limit: Max number of results (default 15)
1573
+ *
1574
+ * This endpoint searches for files in the project root directory
1575
+ * to support @-file autocomplete in the message composer.
1576
+ */
1577
+ app.get('/api/files', async (req, res) => {
1578
+ const query = req.query.q || '';
1579
+ const limit = Math.min(parseInt(req.query.limit, 10) || 15, 50);
1580
+ // Get project root (parent of dataDir, or use projectRoot if available)
1581
+ const searchRoot = options.projectRoot || path.dirname(dataDir);
1582
+ try {
1583
+ const results = await searchFiles(searchRoot, query, limit);
1584
+ res.json({ files: results, query, searchRoot: path.basename(searchRoot) });
1585
+ }
1586
+ catch (err) {
1587
+ console.error('[api] File search error:', err);
1588
+ res.status(500).json({ error: 'Failed to search files', files: [] });
1589
+ }
1590
+ });
752
1591
  // Bridge API endpoint - returns multi-project data
753
1592
  // This is a placeholder that returns empty data when not in bridge mode
754
1593
  // The actual bridge data comes from MultiProjectClient when running `agent-relay bridge`
@@ -774,10 +1613,300 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
774
1613
  res.status(500).json({ error: 'Failed to load bridge data' });
775
1614
  }
776
1615
  });
1616
+ // ===== Conversation History API =====
1617
+ /**
1618
+ * GET /api/history/sessions - List all sessions with filters
1619
+ * Query params:
1620
+ * - agent: Filter by agent name
1621
+ * - since: Filter sessions started after this timestamp (ms)
1622
+ * - limit: Max number of sessions (default 50)
1623
+ */
1624
+ app.get('/api/history/sessions', async (req, res) => {
1625
+ if (!storage) {
1626
+ return res.status(503).json({ error: 'Storage not configured' });
1627
+ }
1628
+ try {
1629
+ const query = {};
1630
+ if (req.query.agent && typeof req.query.agent === 'string') {
1631
+ query.agentName = req.query.agent;
1632
+ }
1633
+ if (req.query.since) {
1634
+ query.since = parseInt(req.query.since, 10);
1635
+ }
1636
+ query.limit = req.query.limit ? parseInt(req.query.limit, 10) : 50;
1637
+ const sessions = storage.getSessions
1638
+ ? await storage.getSessions(query)
1639
+ : [];
1640
+ const result = sessions.map(s => ({
1641
+ id: s.id,
1642
+ agentName: s.agentName,
1643
+ cli: s.cli,
1644
+ startedAt: new Date(s.startedAt).toISOString(),
1645
+ endedAt: s.endedAt ? new Date(s.endedAt).toISOString() : undefined,
1646
+ duration: formatDuration(s.startedAt, s.endedAt),
1647
+ messageCount: s.messageCount,
1648
+ summary: s.summary,
1649
+ isActive: !s.endedAt,
1650
+ closedBy: s.closedBy,
1651
+ }));
1652
+ res.json({ sessions: result });
1653
+ }
1654
+ catch (err) {
1655
+ console.error('Failed to fetch sessions', err);
1656
+ res.status(500).json({ error: 'Failed to fetch sessions' });
1657
+ }
1658
+ });
1659
+ /**
1660
+ * GET /api/history/messages - Get messages with filters
1661
+ * Query params:
1662
+ * - from: Filter by sender
1663
+ * - to: Filter by recipient
1664
+ * - thread: Filter by thread ID
1665
+ * - since: Filter messages after this timestamp (ms)
1666
+ * - limit: Max number of messages (default 100)
1667
+ * - order: 'asc' or 'desc' (default 'desc')
1668
+ * - search: Search in message body (basic substring match)
1669
+ */
1670
+ app.get('/api/history/messages', async (req, res) => {
1671
+ if (!storage) {
1672
+ return res.status(503).json({ error: 'Storage not configured' });
1673
+ }
1674
+ try {
1675
+ const query = {};
1676
+ if (req.query.from && typeof req.query.from === 'string') {
1677
+ query.from = req.query.from;
1678
+ }
1679
+ if (req.query.to && typeof req.query.to === 'string') {
1680
+ query.to = req.query.to;
1681
+ }
1682
+ if (req.query.thread && typeof req.query.thread === 'string') {
1683
+ query.thread = req.query.thread;
1684
+ }
1685
+ if (req.query.since) {
1686
+ query.sinceTs = parseInt(req.query.since, 10);
1687
+ }
1688
+ query.limit = req.query.limit ? parseInt(req.query.limit, 10) : 100;
1689
+ query.order = req.query.order || 'desc';
1690
+ let messages = await storage.getMessages(query);
1691
+ // Filter out messages from/to internal system agents (e.g., __spawner__)
1692
+ messages = messages.filter(m => !isInternalAgent(m.from) && !isInternalAgent(m.to));
1693
+ // Client-side search filter (basic substring match)
1694
+ const searchTerm = req.query.search;
1695
+ if (searchTerm && searchTerm.trim()) {
1696
+ const lowerSearch = searchTerm.toLowerCase();
1697
+ messages = messages.filter(m => m.body.toLowerCase().includes(lowerSearch) ||
1698
+ m.from.toLowerCase().includes(lowerSearch) ||
1699
+ m.to.toLowerCase().includes(lowerSearch));
1700
+ }
1701
+ const result = messages.map(m => ({
1702
+ id: m.id,
1703
+ from: m.from,
1704
+ to: m.to,
1705
+ content: m.body,
1706
+ timestamp: new Date(m.ts).toISOString(),
1707
+ thread: m.thread,
1708
+ isBroadcast: m.is_broadcast,
1709
+ isUrgent: m.is_urgent,
1710
+ status: m.status,
1711
+ }));
1712
+ res.json({ messages: result });
1713
+ }
1714
+ catch (err) {
1715
+ console.error('Failed to fetch messages', err);
1716
+ res.status(500).json({ error: 'Failed to fetch messages' });
1717
+ }
1718
+ });
1719
+ /**
1720
+ * GET /api/history/conversations - Get unique conversations (agent pairs)
1721
+ * Returns list of agent pairs that have exchanged messages
1722
+ */
1723
+ app.get('/api/history/conversations', async (req, res) => {
1724
+ if (!storage) {
1725
+ return res.status(503).json({ error: 'Storage not configured' });
1726
+ }
1727
+ try {
1728
+ // Get all messages to build conversation list
1729
+ const messages = await storage.getMessages({ limit: 1000, order: 'desc' });
1730
+ // Build unique conversation pairs
1731
+ const conversationMap = new Map();
1732
+ for (const msg of messages) {
1733
+ // Skip broadcasts for conversation pairing
1734
+ if (msg.to === '*' || msg.is_broadcast)
1735
+ continue;
1736
+ // Skip messages from/to internal system agents (e.g., __spawner__)
1737
+ if (isInternalAgent(msg.from) || isInternalAgent(msg.to))
1738
+ continue;
1739
+ // Create normalized key (sorted participants)
1740
+ const participants = [msg.from, msg.to].sort();
1741
+ const key = participants.join(':');
1742
+ const existing = conversationMap.get(key);
1743
+ if (existing) {
1744
+ existing.messageCount++;
1745
+ }
1746
+ else {
1747
+ conversationMap.set(key, {
1748
+ participants,
1749
+ lastMessage: msg.body.substring(0, 100),
1750
+ lastTimestamp: new Date(msg.ts).toISOString(),
1751
+ messageCount: 1,
1752
+ });
1753
+ }
1754
+ }
1755
+ // Convert to array sorted by last timestamp
1756
+ const conversations = Array.from(conversationMap.values())
1757
+ .sort((a, b) => new Date(b.lastTimestamp).getTime() - new Date(a.lastTimestamp).getTime());
1758
+ res.json({ conversations });
1759
+ }
1760
+ catch (err) {
1761
+ console.error('Failed to fetch conversations', err);
1762
+ res.status(500).json({ error: 'Failed to fetch conversations' });
1763
+ }
1764
+ });
1765
+ /**
1766
+ * GET /api/history/message/:id - Get a single message by ID
1767
+ */
1768
+ app.get('/api/history/message/:id', async (req, res) => {
1769
+ if (!storage) {
1770
+ return res.status(503).json({ error: 'Storage not configured' });
1771
+ }
1772
+ try {
1773
+ const { id } = req.params;
1774
+ const message = storage.getMessageById
1775
+ ? await storage.getMessageById(id)
1776
+ : null;
1777
+ if (!message) {
1778
+ return res.status(404).json({ error: 'Message not found' });
1779
+ }
1780
+ res.json({
1781
+ id: message.id,
1782
+ from: message.from,
1783
+ to: message.to,
1784
+ content: message.body,
1785
+ timestamp: new Date(message.ts).toISOString(),
1786
+ thread: message.thread,
1787
+ isBroadcast: message.is_broadcast,
1788
+ isUrgent: message.is_urgent,
1789
+ status: message.status,
1790
+ data: message.data,
1791
+ });
1792
+ }
1793
+ catch (err) {
1794
+ console.error('Failed to fetch message', err);
1795
+ res.status(500).json({ error: 'Failed to fetch message' });
1796
+ }
1797
+ });
1798
+ /**
1799
+ * GET /api/history/stats - Get storage statistics
1800
+ */
1801
+ app.get('/api/history/stats', async (req, res) => {
1802
+ if (!storage) {
1803
+ return res.status(503).json({ error: 'Storage not configured' });
1804
+ }
1805
+ try {
1806
+ // Get stats from SQLite adapter if available
1807
+ if (storage instanceof SqliteStorageAdapter) {
1808
+ const stats = await storage.getStats();
1809
+ const sessions = await storage.getSessions({ limit: 1000 });
1810
+ // Calculate additional stats
1811
+ const activeSessions = sessions.filter(s => !s.endedAt).length;
1812
+ const uniqueAgents = new Set(sessions.map(s => s.agentName)).size;
1813
+ res.json({
1814
+ messageCount: stats.messageCount,
1815
+ sessionCount: stats.sessionCount,
1816
+ activeSessions,
1817
+ uniqueAgents,
1818
+ oldestMessageDate: stats.oldestMessageTs
1819
+ ? new Date(stats.oldestMessageTs).toISOString()
1820
+ : null,
1821
+ });
1822
+ }
1823
+ else {
1824
+ // Basic stats for other adapters
1825
+ const messages = await storage.getMessages({ limit: 1 });
1826
+ res.json({
1827
+ messageCount: messages.length > 0 ? 'unknown' : 0,
1828
+ sessionCount: 'unknown',
1829
+ activeSessions: 'unknown',
1830
+ uniqueAgents: 'unknown',
1831
+ });
1832
+ }
1833
+ }
1834
+ catch (err) {
1835
+ console.error('Failed to fetch stats', err);
1836
+ res.status(500).json({ error: 'Failed to fetch stats' });
1837
+ }
1838
+ });
1839
+ // ===== Agent Logs API =====
1840
+ /**
1841
+ * GET /api/logs/:name - Get historical logs for a spawned agent
1842
+ * Query params:
1843
+ * - limit: Max lines to return (default 500)
1844
+ * - raw: If 'true', return raw output instead of cleaned lines
1845
+ */
1846
+ app.get('/api/logs/:name', (req, res) => {
1847
+ if (!spawner) {
1848
+ return res.status(503).json({ error: 'Spawner not enabled' });
1849
+ }
1850
+ const { name } = req.params;
1851
+ const limit = req.query.limit ? parseInt(req.query.limit, 10) : 500;
1852
+ const raw = req.query.raw === 'true';
1853
+ // Check if worker exists
1854
+ if (!spawner.hasWorker(name)) {
1855
+ return res.status(404).json({ error: `Agent ${name} not found` });
1856
+ }
1857
+ try {
1858
+ if (raw) {
1859
+ const output = spawner.getWorkerRawOutput(name);
1860
+ res.json({
1861
+ name,
1862
+ raw: true,
1863
+ output: output || '',
1864
+ timestamp: new Date().toISOString(),
1865
+ });
1866
+ }
1867
+ else {
1868
+ const lines = spawner.getWorkerOutput(name, limit);
1869
+ res.json({
1870
+ name,
1871
+ raw: false,
1872
+ lines: lines || [],
1873
+ lineCount: lines?.length || 0,
1874
+ timestamp: new Date().toISOString(),
1875
+ });
1876
+ }
1877
+ }
1878
+ catch (err) {
1879
+ console.error(`Failed to get logs for ${name}:`, err);
1880
+ res.status(500).json({ error: 'Failed to get logs' });
1881
+ }
1882
+ });
1883
+ /**
1884
+ * GET /api/logs - List all agents with available logs
1885
+ */
1886
+ app.get('/api/logs', (req, res) => {
1887
+ if (!spawner) {
1888
+ return res.status(503).json({ error: 'Spawner not enabled' });
1889
+ }
1890
+ try {
1891
+ const workers = spawner.getActiveWorkers();
1892
+ const agents = workers.map(w => ({
1893
+ name: w.name,
1894
+ cli: w.cli,
1895
+ pid: w.pid,
1896
+ spawnedAt: new Date(w.spawnedAt).toISOString(),
1897
+ hasLogs: true,
1898
+ }));
1899
+ res.json({ agents });
1900
+ }
1901
+ catch (err) {
1902
+ console.error('Failed to list agents with logs:', err);
1903
+ res.status(500).json({ error: 'Failed to list agents' });
1904
+ }
1905
+ });
777
1906
  // ===== Agent Spawn API =====
778
1907
  /**
779
1908
  * POST /api/spawn - Spawn a new agent
780
- * Body: { name: string, cli?: string, task?: string, team?: string }
1909
+ * Body: { name: string, cli?: string, task?: string, team?: string, shadowMode?, shadowAgent?, shadowOf?, shadowTriggers?, shadowSpeakOn? }
781
1910
  */
782
1911
  app.post('/api/spawn', async (req, res) => {
783
1912
  if (!spawner) {
@@ -786,7 +1915,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
786
1915
  error: 'Spawner not enabled. Start dashboard with enableSpawner: true',
787
1916
  });
788
1917
  }
789
- const { name, cli = 'claude', task = '', team } = req.body;
1918
+ const { name, cli = 'claude', task = '', team, shadowMode, shadowAgent, shadowOf, shadowTriggers, shadowSpeakOn, } = req.body;
790
1919
  if (!name || typeof name !== 'string') {
791
1920
  return res.status(400).json({
792
1921
  success: false,
@@ -799,6 +1928,11 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
799
1928
  cli,
800
1929
  task,
801
1930
  team: team || undefined, // Optional team name
1931
+ shadowMode,
1932
+ shadowAgent,
1933
+ shadowOf,
1934
+ shadowTriggers,
1935
+ shadowSpeakOn,
802
1936
  };
803
1937
  const result = await spawner.spawn(request);
804
1938
  if (result.success) {
@@ -816,6 +1950,95 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
816
1950
  });
817
1951
  }
818
1952
  });
1953
+ /**
1954
+ * POST /api/spawn/architect - Spawn an Architect agent for bridge mode
1955
+ * Body: { cli?: string }
1956
+ */
1957
+ app.post('/api/spawn/architect', async (req, res) => {
1958
+ if (!spawner) {
1959
+ return res.status(503).json({
1960
+ success: false,
1961
+ error: 'Spawner not enabled. Start dashboard with enableSpawner: true',
1962
+ });
1963
+ }
1964
+ const { cli = 'claude' } = req.body;
1965
+ // Check if Architect already exists
1966
+ const activeWorkers = spawner.getActiveWorkers();
1967
+ if (activeWorkers.some(w => w.name.toLowerCase() === 'architect')) {
1968
+ return res.status(409).json({
1969
+ success: false,
1970
+ error: 'Architect agent already running',
1971
+ });
1972
+ }
1973
+ // Get bridge state for project context
1974
+ const bridgeStatePath = path.join(dataDir, 'bridge-state.json');
1975
+ let projectContext = 'No bridge projects connected.';
1976
+ if (fs.existsSync(bridgeStatePath)) {
1977
+ try {
1978
+ const bridgeState = JSON.parse(fs.readFileSync(bridgeStatePath, 'utf-8'));
1979
+ if (bridgeState.projects && bridgeState.projects.length > 0) {
1980
+ projectContext = bridgeState.projects
1981
+ .map((p) => `- ${p.id}: ${p.path} (Lead: ${p.lead?.name || 'none'})`)
1982
+ .join('\n');
1983
+ }
1984
+ }
1985
+ catch (e) {
1986
+ console.error('[api] Failed to read bridge state:', e);
1987
+ }
1988
+ }
1989
+ // Build the architect prompt
1990
+ const architectPrompt = `You are the Architect, a cross-project coordinator overseeing multiple codebases.
1991
+
1992
+ ## Connected Projects
1993
+ ${projectContext}
1994
+
1995
+ ## Your Role
1996
+ - Coordinate high-level work across all projects
1997
+ - Assign tasks to project leads
1998
+ - Ensure consistency and resolve cross-project dependencies
1999
+ - Review overall architecture decisions
2000
+
2001
+ ## Cross-Project Messaging
2002
+
2003
+ Use this syntax to message agents in specific projects:
2004
+
2005
+ \`\`\`
2006
+ ->relay:project-id:AgentName <<<
2007
+ Your message to this agent>>>
2008
+
2009
+ ->relay:project-id:* <<<
2010
+ Broadcast to all agents in a project>>>
2011
+
2012
+ ->relay:*:* <<<
2013
+ Broadcast to ALL agents in ALL projects>>>
2014
+ \`\`\`
2015
+
2016
+ ## Getting Started
2017
+ 1. Check in with each project lead to understand current status
2018
+ 2. Identify cross-project dependencies
2019
+ 3. Coordinate work across teams
2020
+
2021
+ Start by greeting the project leads and asking for status updates.`;
2022
+ try {
2023
+ const result = await spawner.spawn({
2024
+ name: 'Architect',
2025
+ cli,
2026
+ task: architectPrompt,
2027
+ });
2028
+ if (result.success) {
2029
+ broadcastData().catch(() => { });
2030
+ }
2031
+ res.json(result);
2032
+ }
2033
+ catch (err) {
2034
+ console.error('[api] Architect spawn error:', err);
2035
+ res.status(500).json({
2036
+ success: false,
2037
+ name: 'Architect',
2038
+ error: err.message,
2039
+ });
2040
+ }
2041
+ });
819
2042
  /**
820
2043
  * GET /api/spawned - List active spawned agents
821
2044
  */
@@ -878,7 +2101,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
878
2101
  if (fs.existsSync(dataDir)) {
879
2102
  console.log(`Watching ${dataDir} for changes...`);
880
2103
  fs.watch(dataDir, { recursive: true }, (eventType, filename) => {
881
- if (filename && (filename.endsWith('inbox.md') || filename.endsWith('team.json') || filename.endsWith('agents.json'))) {
2104
+ if (filename && (filename.endsWith('inbox.md') || filename.endsWith('team.json') || filename.endsWith('agents.json') || filename.endsWith('processing-state.json'))) {
882
2105
  // Debounce
883
2106
  if (fsWait)
884
2107
  return;