agent-relay 1.0.22 → 1.2.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 (613) hide show
  1. package/README.md +1 -1
  2. package/dist/bridge/shadow-cli.d.ts +17 -0
  3. package/dist/bridge/shadow-cli.d.ts.map +1 -0
  4. package/dist/bridge/shadow-cli.js +75 -0
  5. package/dist/bridge/shadow-cli.js.map +1 -0
  6. package/dist/bridge/shadow-config.d.ts +87 -0
  7. package/dist/bridge/shadow-config.d.ts.map +1 -0
  8. package/dist/bridge/shadow-config.js +134 -0
  9. package/dist/bridge/shadow-config.js.map +1 -0
  10. package/dist/bridge/spawner.d.ts +68 -1
  11. package/dist/bridge/spawner.d.ts.map +1 -1
  12. package/dist/bridge/spawner.js +360 -16
  13. package/dist/bridge/spawner.js.map +1 -1
  14. package/dist/bridge/types.d.ts +67 -0
  15. package/dist/bridge/types.d.ts.map +1 -1
  16. package/dist/cli/index.js +1196 -15
  17. package/dist/cli/index.js.map +1 -1
  18. package/dist/cloud/api/auth.d.ts +20 -0
  19. package/dist/cloud/api/auth.d.ts.map +1 -0
  20. package/dist/cloud/api/auth.js +128 -0
  21. package/dist/cloud/api/auth.js.map +1 -0
  22. package/dist/cloud/api/billing.d.ts +17 -0
  23. package/dist/cloud/api/billing.d.ts.map +1 -0
  24. package/dist/cloud/api/billing.js +353 -0
  25. package/dist/cloud/api/billing.js.map +1 -0
  26. package/dist/cloud/api/cli-pty-runner.d.ts +54 -0
  27. package/dist/cloud/api/cli-pty-runner.d.ts.map +1 -0
  28. package/dist/cloud/api/cli-pty-runner.js +119 -0
  29. package/dist/cloud/api/cli-pty-runner.js.map +1 -0
  30. package/dist/cloud/api/coordinators.d.ts +8 -0
  31. package/dist/cloud/api/coordinators.d.ts.map +1 -0
  32. package/dist/cloud/api/coordinators.js +347 -0
  33. package/dist/cloud/api/coordinators.js.map +1 -0
  34. package/dist/cloud/api/daemons.d.ts +12 -0
  35. package/dist/cloud/api/daemons.d.ts.map +1 -0
  36. package/dist/cloud/api/daemons.js +320 -0
  37. package/dist/cloud/api/daemons.js.map +1 -0
  38. package/dist/cloud/api/generic-webhooks.d.ts +8 -0
  39. package/dist/cloud/api/generic-webhooks.d.ts.map +1 -0
  40. package/dist/cloud/api/generic-webhooks.js +129 -0
  41. package/dist/cloud/api/generic-webhooks.js.map +1 -0
  42. package/dist/cloud/api/git.d.ts +8 -0
  43. package/dist/cloud/api/git.d.ts.map +1 -0
  44. package/dist/cloud/api/git.js +131 -0
  45. package/dist/cloud/api/git.js.map +1 -0
  46. package/dist/cloud/api/github-app.d.ts +11 -0
  47. package/dist/cloud/api/github-app.d.ts.map +1 -0
  48. package/dist/cloud/api/github-app.js +189 -0
  49. package/dist/cloud/api/github-app.js.map +1 -0
  50. package/dist/cloud/api/middleware/planLimits.d.ts +43 -0
  51. package/dist/cloud/api/middleware/planLimits.d.ts.map +1 -0
  52. package/dist/cloud/api/middleware/planLimits.js +202 -0
  53. package/dist/cloud/api/middleware/planLimits.js.map +1 -0
  54. package/dist/cloud/api/monitoring.d.ts +11 -0
  55. package/dist/cloud/api/monitoring.d.ts.map +1 -0
  56. package/dist/cloud/api/monitoring.js +578 -0
  57. package/dist/cloud/api/monitoring.js.map +1 -0
  58. package/dist/cloud/api/nango-auth.d.ts +9 -0
  59. package/dist/cloud/api/nango-auth.d.ts.map +1 -0
  60. package/dist/cloud/api/nango-auth.js +377 -0
  61. package/dist/cloud/api/nango-auth.js.map +1 -0
  62. package/dist/cloud/api/onboarding.d.ts +15 -0
  63. package/dist/cloud/api/onboarding.d.ts.map +1 -0
  64. package/dist/cloud/api/onboarding.js +588 -0
  65. package/dist/cloud/api/onboarding.js.map +1 -0
  66. package/dist/cloud/api/policy.d.ts +8 -0
  67. package/dist/cloud/api/policy.d.ts.map +1 -0
  68. package/dist/cloud/api/policy.js +229 -0
  69. package/dist/cloud/api/policy.js.map +1 -0
  70. package/dist/cloud/api/providers.d.ts +7 -0
  71. package/dist/cloud/api/providers.d.ts.map +1 -0
  72. package/dist/cloud/api/providers.js +507 -0
  73. package/dist/cloud/api/providers.js.map +1 -0
  74. package/dist/cloud/api/repos.d.ts +7 -0
  75. package/dist/cloud/api/repos.d.ts.map +1 -0
  76. package/dist/cloud/api/repos.js +314 -0
  77. package/dist/cloud/api/repos.js.map +1 -0
  78. package/dist/cloud/api/teams.d.ts +7 -0
  79. package/dist/cloud/api/teams.d.ts.map +1 -0
  80. package/dist/cloud/api/teams.js +279 -0
  81. package/dist/cloud/api/teams.js.map +1 -0
  82. package/dist/cloud/api/test-helpers.d.ts +10 -0
  83. package/dist/cloud/api/test-helpers.d.ts.map +1 -0
  84. package/dist/cloud/api/test-helpers.js +575 -0
  85. package/dist/cloud/api/test-helpers.js.map +1 -0
  86. package/dist/cloud/api/usage.d.ts +7 -0
  87. package/dist/cloud/api/usage.d.ts.map +1 -0
  88. package/dist/cloud/api/usage.js +98 -0
  89. package/dist/cloud/api/usage.js.map +1 -0
  90. package/dist/cloud/api/webhooks.d.ts +7 -0
  91. package/dist/cloud/api/webhooks.d.ts.map +1 -0
  92. package/dist/cloud/api/webhooks.js +496 -0
  93. package/dist/cloud/api/webhooks.js.map +1 -0
  94. package/dist/cloud/api/workspaces.d.ts +7 -0
  95. package/dist/cloud/api/workspaces.d.ts.map +1 -0
  96. package/dist/cloud/api/workspaces.js +727 -0
  97. package/dist/cloud/api/workspaces.js.map +1 -0
  98. package/dist/cloud/billing/index.d.ts +9 -0
  99. package/dist/cloud/billing/index.d.ts.map +1 -0
  100. package/dist/cloud/billing/index.js +9 -0
  101. package/dist/cloud/billing/index.js.map +1 -0
  102. package/dist/cloud/billing/plans.d.ts +39 -0
  103. package/dist/cloud/billing/plans.d.ts.map +1 -0
  104. package/dist/cloud/billing/plans.js +245 -0
  105. package/dist/cloud/billing/plans.js.map +1 -0
  106. package/dist/cloud/billing/service.d.ts +80 -0
  107. package/dist/cloud/billing/service.d.ts.map +1 -0
  108. package/dist/cloud/billing/service.js +388 -0
  109. package/dist/cloud/billing/service.js.map +1 -0
  110. package/dist/cloud/billing/types.d.ts +141 -0
  111. package/dist/cloud/billing/types.d.ts.map +1 -0
  112. package/dist/cloud/billing/types.js +7 -0
  113. package/dist/cloud/billing/types.js.map +1 -0
  114. package/dist/cloud/config.d.ts +66 -0
  115. package/dist/cloud/config.d.ts.map +1 -0
  116. package/dist/cloud/config.js +92 -0
  117. package/dist/cloud/config.js.map +1 -0
  118. package/dist/cloud/db/drizzle.d.ts +215 -0
  119. package/dist/cloud/db/drizzle.d.ts.map +1 -0
  120. package/dist/cloud/db/drizzle.js +1083 -0
  121. package/dist/cloud/db/drizzle.js.map +1 -0
  122. package/dist/cloud/db/index.d.ts +35 -0
  123. package/dist/cloud/db/index.d.ts.map +1 -0
  124. package/dist/cloud/db/index.js +52 -0
  125. package/dist/cloud/db/index.js.map +1 -0
  126. package/dist/cloud/db/schema.d.ts +4519 -0
  127. package/dist/cloud/db/schema.d.ts.map +1 -0
  128. package/dist/cloud/db/schema.js +547 -0
  129. package/dist/cloud/db/schema.js.map +1 -0
  130. package/dist/cloud/index.d.ts +12 -0
  131. package/dist/cloud/index.d.ts.map +1 -0
  132. package/dist/cloud/index.js +39 -0
  133. package/dist/cloud/index.js.map +1 -0
  134. package/dist/cloud/provisioner/index.d.ts +75 -0
  135. package/dist/cloud/provisioner/index.d.ts.map +1 -0
  136. package/dist/cloud/provisioner/index.js +977 -0
  137. package/dist/cloud/provisioner/index.js.map +1 -0
  138. package/dist/cloud/server.d.ts +17 -0
  139. package/dist/cloud/server.d.ts.map +1 -0
  140. package/dist/cloud/server.js +534 -0
  141. package/dist/cloud/server.js.map +1 -0
  142. package/dist/cloud/services/auto-scaler.d.ts +152 -0
  143. package/dist/cloud/services/auto-scaler.d.ts.map +1 -0
  144. package/dist/cloud/services/auto-scaler.js +439 -0
  145. package/dist/cloud/services/auto-scaler.js.map +1 -0
  146. package/dist/cloud/services/capacity-manager.d.ts +148 -0
  147. package/dist/cloud/services/capacity-manager.d.ts.map +1 -0
  148. package/dist/cloud/services/capacity-manager.js +449 -0
  149. package/dist/cloud/services/capacity-manager.js.map +1 -0
  150. package/dist/cloud/services/ci-agent-spawner.d.ts +49 -0
  151. package/dist/cloud/services/ci-agent-spawner.d.ts.map +1 -0
  152. package/dist/cloud/services/ci-agent-spawner.js +373 -0
  153. package/dist/cloud/services/ci-agent-spawner.js.map +1 -0
  154. package/dist/cloud/services/coordinator.d.ts +62 -0
  155. package/dist/cloud/services/coordinator.d.ts.map +1 -0
  156. package/dist/cloud/services/coordinator.js +389 -0
  157. package/dist/cloud/services/coordinator.js.map +1 -0
  158. package/dist/cloud/services/index.d.ts +12 -0
  159. package/dist/cloud/services/index.d.ts.map +1 -0
  160. package/dist/cloud/services/index.js +15 -0
  161. package/dist/cloud/services/index.js.map +1 -0
  162. package/dist/cloud/services/mention-handler.d.ts +65 -0
  163. package/dist/cloud/services/mention-handler.d.ts.map +1 -0
  164. package/dist/cloud/services/mention-handler.js +405 -0
  165. package/dist/cloud/services/mention-handler.js.map +1 -0
  166. package/dist/cloud/services/nango.d.ts +126 -0
  167. package/dist/cloud/services/nango.d.ts.map +1 -0
  168. package/dist/cloud/services/nango.js +191 -0
  169. package/dist/cloud/services/nango.js.map +1 -0
  170. package/dist/cloud/services/persistence.d.ts +131 -0
  171. package/dist/cloud/services/persistence.d.ts.map +1 -0
  172. package/dist/cloud/services/persistence.js +200 -0
  173. package/dist/cloud/services/persistence.js.map +1 -0
  174. package/dist/cloud/services/planLimits.d.ts +125 -0
  175. package/dist/cloud/services/planLimits.d.ts.map +1 -0
  176. package/dist/cloud/services/planLimits.js +282 -0
  177. package/dist/cloud/services/planLimits.js.map +1 -0
  178. package/dist/cloud/services/scaling-orchestrator.d.ts +159 -0
  179. package/dist/cloud/services/scaling-orchestrator.d.ts.map +1 -0
  180. package/dist/cloud/services/scaling-orchestrator.js +502 -0
  181. package/dist/cloud/services/scaling-orchestrator.js.map +1 -0
  182. package/dist/cloud/services/scaling-policy.d.ts +121 -0
  183. package/dist/cloud/services/scaling-policy.d.ts.map +1 -0
  184. package/dist/cloud/services/scaling-policy.js +415 -0
  185. package/dist/cloud/services/scaling-policy.js.map +1 -0
  186. package/dist/cloud/vault/index.d.ts +76 -0
  187. package/dist/cloud/vault/index.d.ts.map +1 -0
  188. package/dist/cloud/vault/index.js +219 -0
  189. package/dist/cloud/vault/index.js.map +1 -0
  190. package/dist/cloud/webhooks/index.d.ts +24 -0
  191. package/dist/cloud/webhooks/index.d.ts.map +1 -0
  192. package/dist/cloud/webhooks/index.js +29 -0
  193. package/dist/cloud/webhooks/index.js.map +1 -0
  194. package/dist/cloud/webhooks/parsers/github.d.ts +8 -0
  195. package/dist/cloud/webhooks/parsers/github.d.ts.map +1 -0
  196. package/dist/cloud/webhooks/parsers/github.js +234 -0
  197. package/dist/cloud/webhooks/parsers/github.js.map +1 -0
  198. package/dist/cloud/webhooks/parsers/index.d.ts +23 -0
  199. package/dist/cloud/webhooks/parsers/index.d.ts.map +1 -0
  200. package/dist/cloud/webhooks/parsers/index.js +30 -0
  201. package/dist/cloud/webhooks/parsers/index.js.map +1 -0
  202. package/dist/cloud/webhooks/parsers/linear.d.ts +9 -0
  203. package/dist/cloud/webhooks/parsers/linear.d.ts.map +1 -0
  204. package/dist/cloud/webhooks/parsers/linear.js +258 -0
  205. package/dist/cloud/webhooks/parsers/linear.js.map +1 -0
  206. package/dist/cloud/webhooks/parsers/slack.d.ts +9 -0
  207. package/dist/cloud/webhooks/parsers/slack.d.ts.map +1 -0
  208. package/dist/cloud/webhooks/parsers/slack.js +214 -0
  209. package/dist/cloud/webhooks/parsers/slack.js.map +1 -0
  210. package/dist/cloud/webhooks/responders/github.d.ts +8 -0
  211. package/dist/cloud/webhooks/responders/github.d.ts.map +1 -0
  212. package/dist/cloud/webhooks/responders/github.js +73 -0
  213. package/dist/cloud/webhooks/responders/github.js.map +1 -0
  214. package/dist/cloud/webhooks/responders/index.d.ts +23 -0
  215. package/dist/cloud/webhooks/responders/index.d.ts.map +1 -0
  216. package/dist/cloud/webhooks/responders/index.js +30 -0
  217. package/dist/cloud/webhooks/responders/index.js.map +1 -0
  218. package/dist/cloud/webhooks/responders/linear.d.ts +9 -0
  219. package/dist/cloud/webhooks/responders/linear.d.ts.map +1 -0
  220. package/dist/cloud/webhooks/responders/linear.js +149 -0
  221. package/dist/cloud/webhooks/responders/linear.js.map +1 -0
  222. package/dist/cloud/webhooks/responders/slack.d.ts +20 -0
  223. package/dist/cloud/webhooks/responders/slack.d.ts.map +1 -0
  224. package/dist/cloud/webhooks/responders/slack.js +178 -0
  225. package/dist/cloud/webhooks/responders/slack.js.map +1 -0
  226. package/dist/cloud/webhooks/router.d.ts +25 -0
  227. package/dist/cloud/webhooks/router.d.ts.map +1 -0
  228. package/dist/cloud/webhooks/router.js +504 -0
  229. package/dist/cloud/webhooks/router.js.map +1 -0
  230. package/dist/cloud/webhooks/rules-engine.d.ts +24 -0
  231. package/dist/cloud/webhooks/rules-engine.d.ts.map +1 -0
  232. package/dist/cloud/webhooks/rules-engine.js +287 -0
  233. package/dist/cloud/webhooks/rules-engine.js.map +1 -0
  234. package/dist/cloud/webhooks/types.d.ts +186 -0
  235. package/dist/cloud/webhooks/types.d.ts.map +1 -0
  236. package/dist/cloud/webhooks/types.js +8 -0
  237. package/dist/cloud/webhooks/types.js.map +1 -0
  238. package/dist/continuity/formatter.d.ts +51 -0
  239. package/dist/continuity/formatter.d.ts.map +1 -0
  240. package/dist/continuity/formatter.js +313 -0
  241. package/dist/continuity/formatter.js.map +1 -0
  242. package/dist/continuity/handoff-store.d.ts +67 -0
  243. package/dist/continuity/handoff-store.d.ts.map +1 -0
  244. package/dist/continuity/handoff-store.js +472 -0
  245. package/dist/continuity/handoff-store.js.map +1 -0
  246. package/dist/continuity/index.d.ts +45 -0
  247. package/dist/continuity/index.d.ts.map +1 -0
  248. package/dist/continuity/index.js +48 -0
  249. package/dist/continuity/index.js.map +1 -0
  250. package/dist/continuity/ledger-store.d.ts +110 -0
  251. package/dist/continuity/ledger-store.d.ts.map +1 -0
  252. package/dist/continuity/ledger-store.js +500 -0
  253. package/dist/continuity/ledger-store.js.map +1 -0
  254. package/dist/continuity/manager.d.ts +178 -0
  255. package/dist/continuity/manager.d.ts.map +1 -0
  256. package/dist/continuity/manager.js +562 -0
  257. package/dist/continuity/manager.js.map +1 -0
  258. package/dist/continuity/parser.d.ts +76 -0
  259. package/dist/continuity/parser.d.ts.map +1 -0
  260. package/dist/continuity/parser.js +579 -0
  261. package/dist/continuity/parser.js.map +1 -0
  262. package/dist/continuity/types.d.ts +180 -0
  263. package/dist/continuity/types.d.ts.map +1 -0
  264. package/dist/continuity/types.js +9 -0
  265. package/dist/continuity/types.js.map +1 -0
  266. package/dist/daemon/agent-manager.d.ts +114 -0
  267. package/dist/daemon/agent-manager.d.ts.map +1 -0
  268. package/dist/daemon/agent-manager.js +513 -0
  269. package/dist/daemon/agent-manager.js.map +1 -0
  270. package/dist/daemon/agent-registry.d.ts +34 -0
  271. package/dist/daemon/agent-registry.d.ts.map +1 -1
  272. package/dist/daemon/agent-registry.js +45 -2
  273. package/dist/daemon/agent-registry.js.map +1 -1
  274. package/dist/daemon/api.d.ts +81 -0
  275. package/dist/daemon/api.d.ts.map +1 -0
  276. package/dist/daemon/api.js +554 -0
  277. package/dist/daemon/api.js.map +1 -0
  278. package/dist/daemon/cli-auth.d.ts +67 -0
  279. package/dist/daemon/cli-auth.d.ts.map +1 -0
  280. package/dist/daemon/cli-auth.js +537 -0
  281. package/dist/daemon/cli-auth.js.map +1 -0
  282. package/dist/daemon/cloud-sync.d.ts +101 -0
  283. package/dist/daemon/cloud-sync.d.ts.map +1 -0
  284. package/dist/daemon/cloud-sync.js +263 -0
  285. package/dist/daemon/cloud-sync.js.map +1 -0
  286. package/dist/daemon/index.d.ts +4 -0
  287. package/dist/daemon/index.d.ts.map +1 -1
  288. package/dist/daemon/index.js +6 -0
  289. package/dist/daemon/index.js.map +1 -1
  290. package/dist/daemon/orchestrator.d.ts +155 -0
  291. package/dist/daemon/orchestrator.d.ts.map +1 -0
  292. package/dist/daemon/orchestrator.js +766 -0
  293. package/dist/daemon/orchestrator.js.map +1 -0
  294. package/dist/daemon/router.d.ts +29 -0
  295. package/dist/daemon/router.d.ts.map +1 -1
  296. package/dist/daemon/router.js +143 -21
  297. package/dist/daemon/router.js.map +1 -1
  298. package/dist/daemon/server.d.ts +42 -0
  299. package/dist/daemon/server.d.ts.map +1 -1
  300. package/dist/daemon/server.js +199 -16
  301. package/dist/daemon/server.js.map +1 -1
  302. package/dist/daemon/services/browser-testing.d.ts +88 -0
  303. package/dist/daemon/services/browser-testing.d.ts.map +1 -0
  304. package/dist/daemon/services/browser-testing.js +244 -0
  305. package/dist/daemon/services/browser-testing.js.map +1 -0
  306. package/dist/daemon/services/container-spawner.d.ts +135 -0
  307. package/dist/daemon/services/container-spawner.d.ts.map +1 -0
  308. package/dist/daemon/services/container-spawner.js +313 -0
  309. package/dist/daemon/services/container-spawner.js.map +1 -0
  310. package/dist/daemon/types.d.ts +131 -0
  311. package/dist/daemon/types.d.ts.map +1 -0
  312. package/dist/daemon/types.js +6 -0
  313. package/dist/daemon/types.js.map +1 -0
  314. package/dist/daemon/workspace-manager.d.ts +75 -0
  315. package/dist/daemon/workspace-manager.d.ts.map +1 -0
  316. package/dist/daemon/workspace-manager.js +289 -0
  317. package/dist/daemon/workspace-manager.js.map +1 -0
  318. package/dist/dashboard/out/404.html +1 -1
  319. package/dist/dashboard/out/_next/static/chunks/116-2502180def231162.js +1 -0
  320. package/dist/dashboard/out/_next/static/chunks/282-980c2eb8fff20123.js +1 -0
  321. package/dist/dashboard/out/_next/static/chunks/480-2d4111711d4e473c.js +1 -0
  322. package/dist/dashboard/out/_next/static/chunks/724-73c1ee5f60abe860.js +9 -0
  323. package/dist/dashboard/out/_next/static/chunks/766-c3a14283c88d815b.js +1 -0
  324. package/dist/dashboard/out/_next/static/chunks/app/app/page-7120be68bea622f3.js +1 -0
  325. package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-dc2e3a1a22478efc.js +1 -0
  326. package/dist/dashboard/out/_next/static/chunks/app/history/page-56a8b4616a90dc43.js +1 -0
  327. package/dist/dashboard/out/_next/static/chunks/app/layout-2433bb48965f4333.js +1 -0
  328. package/dist/dashboard/out/_next/static/chunks/app/login/page-3eac37ea6f5dd153.js +1 -0
  329. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-1081dd190a331a91.js +1 -0
  330. package/dist/dashboard/out/_next/static/chunks/app/page-daf87e86f783f980.js +1 -0
  331. package/dist/dashboard/out/_next/static/chunks/app/pricing/page-4d72d5a5d8a9b618.js +1 -0
  332. package/dist/dashboard/out/_next/static/chunks/app/providers/page-b68a681526eb145e.js +1 -0
  333. package/dist/dashboard/out/_next/static/chunks/app/signup/page-fee4ed1709070bcd.js +1 -0
  334. package/dist/dashboard/out/_next/static/chunks/e868780c-48e5f147c90a3a41.js +18 -0
  335. package/dist/dashboard/out/_next/static/chunks/{main-e0a1f53fe0617a63.js → main-97850e03d723ea8c.js} +1 -1
  336. package/dist/dashboard/out/_next/static/chunks/main-app-5d692157a8eb1fd9.js +1 -0
  337. package/dist/dashboard/out/_next/static/chunks/webpack-1cdd8ed57114d5e1.js +1 -0
  338. package/dist/dashboard/out/_next/static/css/29852f26181969a0.css +1 -0
  339. package/dist/dashboard/out/_next/static/css/411ce23ffeae9f76.css +1 -0
  340. package/dist/dashboard/out/alt-logos/agent-relay-logo-128.png +0 -0
  341. package/dist/dashboard/out/alt-logos/agent-relay-logo-256.png +0 -0
  342. package/dist/dashboard/out/alt-logos/agent-relay-logo-32.png +0 -0
  343. package/dist/dashboard/out/alt-logos/agent-relay-logo-512.png +0 -0
  344. package/dist/dashboard/out/alt-logos/agent-relay-logo-64.png +0 -0
  345. package/dist/dashboard/out/alt-logos/agent-relay-logo.svg +45 -0
  346. package/dist/dashboard/out/alt-logos/logo.svg +38 -0
  347. package/dist/dashboard/out/alt-logos/monogram-logo-128.png +0 -0
  348. package/dist/dashboard/out/alt-logos/monogram-logo-256.png +0 -0
  349. package/dist/dashboard/out/alt-logos/monogram-logo-32.png +0 -0
  350. package/dist/dashboard/out/alt-logos/monogram-logo-512.png +0 -0
  351. package/dist/dashboard/out/alt-logos/monogram-logo-64.png +0 -0
  352. package/dist/dashboard/out/alt-logos/monogram-logo.svg +38 -0
  353. package/dist/dashboard/out/app.html +1 -0
  354. package/dist/dashboard/out/app.txt +7 -0
  355. package/dist/dashboard/out/connect-repos.html +1 -0
  356. package/dist/dashboard/out/connect-repos.txt +7 -0
  357. package/dist/dashboard/out/history.html +1 -0
  358. package/dist/dashboard/out/history.txt +7 -0
  359. package/dist/dashboard/out/index.html +1 -1
  360. package/dist/dashboard/out/index.txt +2 -2
  361. package/dist/dashboard/out/login.html +6 -0
  362. package/dist/dashboard/out/login.txt +7 -0
  363. package/dist/dashboard/out/metrics.html +1 -515
  364. package/dist/dashboard/out/metrics.txt +2 -2
  365. package/dist/dashboard/out/pricing.html +13 -0
  366. package/dist/dashboard/out/pricing.txt +7 -0
  367. package/dist/dashboard/out/providers.html +1 -0
  368. package/dist/dashboard/out/providers.txt +7 -0
  369. package/dist/dashboard/out/signup.html +6 -0
  370. package/dist/dashboard/out/signup.txt +7 -0
  371. package/dist/dashboard-server/metrics.d.ts.map +1 -1
  372. package/dist/dashboard-server/metrics.js +3 -2
  373. package/dist/dashboard-server/metrics.js.map +1 -1
  374. package/dist/dashboard-server/server.d.ts.map +1 -1
  375. package/dist/dashboard-server/server.js +2653 -130
  376. package/dist/dashboard-server/server.js.map +1 -1
  377. package/dist/hooks/emitter.d.ts +40 -0
  378. package/dist/hooks/emitter.d.ts.map +1 -0
  379. package/dist/hooks/emitter.js +63 -0
  380. package/dist/hooks/emitter.js.map +1 -0
  381. package/dist/hooks/index.d.ts +3 -0
  382. package/dist/hooks/index.d.ts.map +1 -1
  383. package/dist/hooks/index.js +3 -0
  384. package/dist/hooks/index.js.map +1 -1
  385. package/dist/hooks/registry.d.ts +173 -0
  386. package/dist/hooks/registry.d.ts.map +1 -0
  387. package/dist/hooks/registry.js +476 -0
  388. package/dist/hooks/registry.js.map +1 -0
  389. package/dist/hooks/trajectory-hooks.d.ts +52 -0
  390. package/dist/hooks/trajectory-hooks.d.ts.map +1 -0
  391. package/dist/hooks/trajectory-hooks.js +183 -0
  392. package/dist/hooks/trajectory-hooks.js.map +1 -0
  393. package/dist/hooks/types.d.ts +141 -0
  394. package/dist/hooks/types.d.ts.map +1 -1
  395. package/dist/index.d.ts +2 -0
  396. package/dist/index.d.ts.map +1 -1
  397. package/dist/index.js +3 -0
  398. package/dist/index.js.map +1 -1
  399. package/dist/memory/adapters/index.d.ts +8 -0
  400. package/dist/memory/adapters/index.d.ts.map +1 -0
  401. package/dist/memory/adapters/index.js +8 -0
  402. package/dist/memory/adapters/index.js.map +1 -0
  403. package/dist/memory/adapters/inmemory.d.ts +59 -0
  404. package/dist/memory/adapters/inmemory.d.ts.map +1 -0
  405. package/dist/memory/adapters/inmemory.js +195 -0
  406. package/dist/memory/adapters/inmemory.js.map +1 -0
  407. package/dist/memory/adapters/supermemory.d.ts +71 -0
  408. package/dist/memory/adapters/supermemory.d.ts.map +1 -0
  409. package/dist/memory/adapters/supermemory.js +338 -0
  410. package/dist/memory/adapters/supermemory.js.map +1 -0
  411. package/dist/memory/factory.d.ts +48 -0
  412. package/dist/memory/factory.d.ts.map +1 -0
  413. package/dist/memory/factory.js +143 -0
  414. package/dist/memory/factory.js.map +1 -0
  415. package/dist/memory/index.d.ts +32 -0
  416. package/dist/memory/index.d.ts.map +1 -0
  417. package/dist/memory/index.js +32 -0
  418. package/dist/memory/index.js.map +1 -0
  419. package/dist/memory/memory-hooks.d.ts +60 -0
  420. package/dist/memory/memory-hooks.d.ts.map +1 -0
  421. package/dist/memory/memory-hooks.js +313 -0
  422. package/dist/memory/memory-hooks.js.map +1 -0
  423. package/dist/memory/service.d.ts +49 -0
  424. package/dist/memory/service.d.ts.map +1 -0
  425. package/dist/memory/service.js +146 -0
  426. package/dist/memory/service.js.map +1 -0
  427. package/dist/memory/types.d.ts +195 -0
  428. package/dist/memory/types.d.ts.map +1 -0
  429. package/dist/memory/types.js +8 -0
  430. package/dist/memory/types.js.map +1 -0
  431. package/dist/policy/agent-policy.d.ts +225 -0
  432. package/dist/policy/agent-policy.d.ts.map +1 -0
  433. package/dist/policy/agent-policy.js +665 -0
  434. package/dist/policy/agent-policy.js.map +1 -0
  435. package/dist/policy/cloud-policy-fetcher.d.ts +12 -0
  436. package/dist/policy/cloud-policy-fetcher.d.ts.map +1 -0
  437. package/dist/policy/cloud-policy-fetcher.js +64 -0
  438. package/dist/policy/cloud-policy-fetcher.js.map +1 -0
  439. package/dist/protocol/types.d.ts +10 -1
  440. package/dist/protocol/types.d.ts.map +1 -1
  441. package/dist/resiliency/context-persistence.d.ts +140 -0
  442. package/dist/resiliency/context-persistence.d.ts.map +1 -0
  443. package/dist/resiliency/context-persistence.js +397 -0
  444. package/dist/resiliency/context-persistence.js.map +1 -0
  445. package/dist/resiliency/crash-insights.d.ts +156 -0
  446. package/dist/resiliency/crash-insights.d.ts.map +1 -0
  447. package/dist/resiliency/crash-insights.js +492 -0
  448. package/dist/resiliency/crash-insights.js.map +1 -0
  449. package/dist/resiliency/gossip-health.d.ts +137 -0
  450. package/dist/resiliency/gossip-health.d.ts.map +1 -0
  451. package/dist/resiliency/gossip-health.js +241 -0
  452. package/dist/resiliency/gossip-health.js.map +1 -0
  453. package/dist/resiliency/health-monitor.d.ts +97 -0
  454. package/dist/resiliency/health-monitor.d.ts.map +1 -0
  455. package/dist/resiliency/health-monitor.js +291 -0
  456. package/dist/resiliency/health-monitor.js.map +1 -0
  457. package/dist/resiliency/index.d.ts +68 -0
  458. package/dist/resiliency/index.d.ts.map +1 -0
  459. package/dist/resiliency/index.js +68 -0
  460. package/dist/resiliency/index.js.map +1 -0
  461. package/dist/resiliency/leader-watchdog.d.ts +109 -0
  462. package/dist/resiliency/leader-watchdog.d.ts.map +1 -0
  463. package/dist/resiliency/leader-watchdog.js +189 -0
  464. package/dist/resiliency/leader-watchdog.js.map +1 -0
  465. package/dist/resiliency/logger.d.ts +114 -0
  466. package/dist/resiliency/logger.d.ts.map +1 -0
  467. package/dist/resiliency/logger.js +250 -0
  468. package/dist/resiliency/logger.js.map +1 -0
  469. package/dist/resiliency/memory-monitor.d.ts +172 -0
  470. package/dist/resiliency/memory-monitor.d.ts.map +1 -0
  471. package/dist/resiliency/memory-monitor.js +593 -0
  472. package/dist/resiliency/memory-monitor.js.map +1 -0
  473. package/dist/resiliency/metrics.d.ts +115 -0
  474. package/dist/resiliency/metrics.d.ts.map +1 -0
  475. package/dist/resiliency/metrics.js +239 -0
  476. package/dist/resiliency/metrics.js.map +1 -0
  477. package/dist/resiliency/provider-context.d.ts +100 -0
  478. package/dist/resiliency/provider-context.d.ts.map +1 -0
  479. package/dist/resiliency/provider-context.js +360 -0
  480. package/dist/resiliency/provider-context.js.map +1 -0
  481. package/dist/resiliency/stateless-lead.d.ts +149 -0
  482. package/dist/resiliency/stateless-lead.d.ts.map +1 -0
  483. package/dist/resiliency/stateless-lead.js +308 -0
  484. package/dist/resiliency/stateless-lead.js.map +1 -0
  485. package/dist/resiliency/supervisor.d.ts +147 -0
  486. package/dist/resiliency/supervisor.d.ts.map +1 -0
  487. package/dist/resiliency/supervisor.js +459 -0
  488. package/dist/resiliency/supervisor.js.map +1 -0
  489. package/dist/shared/cli-auth-config.d.ts +91 -0
  490. package/dist/shared/cli-auth-config.d.ts.map +1 -0
  491. package/dist/shared/cli-auth-config.js +264 -0
  492. package/dist/shared/cli-auth-config.js.map +1 -0
  493. package/dist/storage/adapter.d.ts +3 -1
  494. package/dist/storage/adapter.d.ts.map +1 -1
  495. package/dist/storage/adapter.js +12 -2
  496. package/dist/storage/adapter.js.map +1 -1
  497. package/dist/storage/sqlite-adapter.d.ts.map +1 -1
  498. package/dist/storage/sqlite-adapter.js +18 -14
  499. package/dist/storage/sqlite-adapter.js.map +1 -1
  500. package/dist/trajectory/config.d.ts +84 -0
  501. package/dist/trajectory/config.d.ts.map +1 -0
  502. package/dist/trajectory/config.js +163 -0
  503. package/dist/trajectory/config.js.map +1 -0
  504. package/dist/trajectory/index.d.ts +8 -0
  505. package/dist/trajectory/index.d.ts.map +1 -0
  506. package/dist/trajectory/index.js +8 -0
  507. package/dist/trajectory/index.js.map +1 -0
  508. package/dist/trajectory/integration.d.ts +292 -0
  509. package/dist/trajectory/integration.d.ts.map +1 -0
  510. package/dist/trajectory/integration.js +834 -0
  511. package/dist/trajectory/integration.js.map +1 -0
  512. package/dist/utils/index.d.ts +1 -0
  513. package/dist/utils/index.d.ts.map +1 -1
  514. package/dist/utils/index.js +1 -0
  515. package/dist/utils/index.js.map +1 -1
  516. package/dist/utils/logger.d.ts +40 -0
  517. package/dist/utils/logger.d.ts.map +1 -0
  518. package/dist/utils/logger.js +84 -0
  519. package/dist/utils/logger.js.map +1 -0
  520. package/dist/utils/project-namespace.d.ts +24 -0
  521. package/dist/utils/project-namespace.d.ts.map +1 -1
  522. package/dist/utils/project-namespace.js +84 -0
  523. package/dist/utils/project-namespace.js.map +1 -1
  524. package/dist/wrapper/client.d.ts +16 -1
  525. package/dist/wrapper/client.d.ts.map +1 -1
  526. package/dist/wrapper/client.js +32 -1
  527. package/dist/wrapper/client.js.map +1 -1
  528. package/dist/wrapper/parser.d.ts +13 -0
  529. package/dist/wrapper/parser.d.ts.map +1 -1
  530. package/dist/wrapper/parser.js +217 -47
  531. package/dist/wrapper/parser.js.map +1 -1
  532. package/dist/wrapper/pty-wrapper.d.ts +219 -17
  533. package/dist/wrapper/pty-wrapper.d.ts.map +1 -1
  534. package/dist/wrapper/pty-wrapper.js +1050 -104
  535. package/dist/wrapper/pty-wrapper.js.map +1 -1
  536. package/dist/wrapper/shared.d.ts +165 -0
  537. package/dist/wrapper/shared.d.ts.map +1 -0
  538. package/dist/wrapper/shared.js +270 -0
  539. package/dist/wrapper/shared.js.map +1 -0
  540. package/dist/wrapper/tmux-wrapper.d.ts +78 -11
  541. package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
  542. package/dist/wrapper/tmux-wrapper.js +567 -106
  543. package/dist/wrapper/tmux-wrapper.js.map +1 -1
  544. package/docs/CLOUD-ARCHITECTURE.md +804 -0
  545. package/docs/CLOUD-ONBOARDING-DESIGN.md +1983 -0
  546. package/docs/HOOKS_API.md +394 -0
  547. package/docs/WRAPPER_EVENTS.md +358 -0
  548. package/docs/agent-policy-snippet.md +40 -0
  549. package/docs/agent-relay-protocol.md +238 -0
  550. package/docs/agent-relay-snippet.md +115 -6
  551. package/docs/archive/EXECUTIVE_SUMMARY.md +358 -0
  552. package/docs/archive/ROADMAP.md +329 -0
  553. package/docs/archive/TESTING_PRESENCE_FEATURES.md +327 -0
  554. package/docs/competitive/GASTOWN.md +451 -0
  555. package/docs/{COMPETITIVE_ANALYSIS.md → competitive/OVERVIEW.md} +1 -0
  556. package/docs/competitive/README.md +34 -0
  557. package/docs/competitive/TMUX_ORCHESTRATOR.md +605 -0
  558. package/docs/dashboard.png +0 -0
  559. package/docs/design/ci-failure-webhooks.md +812 -0
  560. package/docs/design/comprehensive-integrations.md +238 -0
  561. package/docs/design/e2b-sandbox-integration.md +504 -0
  562. package/docs/design/github-app-permissions.md +264 -0
  563. package/docs/guides/CLOUD.md +236 -0
  564. package/docs/guides/LOCAL.md +535 -0
  565. package/docs/guides/SELF-HOSTED.md +494 -0
  566. package/docs/local-testing.md +428 -0
  567. package/docs/proposals/continuous-claude-integration.md +622 -0
  568. package/docs/proposals/custom-commands.md +368 -0
  569. package/docs/proposals/shadow-as-subagent.md +765 -0
  570. package/docs/proposals/slack-bot-integration.md +1457 -0
  571. package/docs/tasks/global-skills-system.tasks.md +230 -0
  572. package/docs/tasks/webhook-integrations.tasks.md +184 -0
  573. package/docs/tasks/workspace-capabilities.tasks.md +121 -0
  574. package/docs/testing/RESILIENCY-TEST-PLAN-2026-01-01.md +366 -0
  575. package/package.json +45 -7
  576. package/scripts/cloud-setup.sh +96 -0
  577. package/scripts/manual-qa.sh +293 -0
  578. package/scripts/postinstall.js +60 -0
  579. package/scripts/run-cloud-qa.sh +220 -0
  580. package/scripts/test-cli-auth/Dockerfile +44 -0
  581. package/scripts/test-cli-auth/Dockerfile.real +79 -0
  582. package/scripts/test-cli-auth/README.md +286 -0
  583. package/scripts/test-cli-auth/ci-test-real-clis.ts +251 -0
  584. package/scripts/test-cli-auth/ci-test-runner.ts +263 -0
  585. package/scripts/test-cli-auth/mock-cli.sh +147 -0
  586. package/scripts/test-cli-auth/package.json +14 -0
  587. package/scripts/test-cli-auth/test-oauth-flow.ts +220 -0
  588. package/scripts/test-pty-input-auto.js +222 -0
  589. package/scripts/test-pty-input.js +150 -0
  590. package/dist/dashboard/out/_next/static/chunks/app/layout-c9d8c5d95e48c6bf.js +0 -1
  591. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-8aa9936bc6c771ab.js +0 -1
  592. package/dist/dashboard/out/_next/static/chunks/app/page-4498be09a5157759.js +0 -1
  593. package/dist/dashboard/out/_next/static/chunks/main-app-bae2e535de00de50.js +0 -1
  594. package/dist/dashboard/out/_next/static/chunks/webpack-c81f7fd28659d64f.js +0 -1
  595. package/dist/dashboard/out/_next/static/css/50ed6996e3df7bdd.css +0 -1
  596. /package/dist/dashboard/out/_next/static/{DXFA-jj8wb3PcY5DX2xcU → H5aWG0udPB4iOUIl_gytz}/_buildManifest.js +0 -0
  597. /package/dist/dashboard/out/_next/static/{DXFA-jj8wb3PcY5DX2xcU → H5aWG0udPB4iOUIl_gytz}/_ssgManifest.js +0 -0
  598. /package/dist/dashboard/out/_next/static/chunks/{117-3bef7b19f3e60751.js → 117-b100311aff8d5c61.js} +0 -0
  599. /package/dist/dashboard/out/_next/static/chunks/{648-6cf686106c891ad3.js → 648-a13d3c2b1be45466.js} +0 -0
  600. /package/dist/dashboard/out/_next/static/chunks/app/_not-found/{page-8ff6572bc7c9bc61.js → page-a4973f3e3c82fb67.js} +0 -0
  601. /package/dist/dashboard/out/_next/static/chunks/{fd9d1056-26bd8d656b496dba.js → fd9d1056-bf46c09eb57e019c.js} +0 -0
  602. /package/docs/{CHANGELOG.md → archive/CHANGELOG.md} +0 -0
  603. /package/docs/{CLI-SIMPLIFICATION-COMPLETE.md → archive/CLI-SIMPLIFICATION-COMPLETE.md} +0 -0
  604. /package/docs/{DESIGN_BRIDGE_STAFFING.md → archive/DESIGN_BRIDGE_STAFFING.md} +0 -0
  605. /package/docs/{DESIGN_V2.md → archive/DESIGN_V2.md} +0 -0
  606. /package/docs/{MONETIZATION.md → archive/MONETIZATION.md} +0 -0
  607. /package/docs/{PROPOSAL-trajectories.md → archive/PROPOSAL-trajectories.md} +0 -0
  608. /package/docs/{SCALING_ANALYSIS.md → archive/SCALING_ANALYSIS.md} +0 -0
  609. /package/docs/{TMUX_IMPLEMENTATION_NOTES.md → archive/TMUX_IMPLEMENTATION_NOTES.md} +0 -0
  610. /package/docs/{TMUX_IMPROVEMENTS.md → archive/TMUX_IMPROVEMENTS.md} +0 -0
  611. /package/docs/{dashboard-v2-plan.md → archive/dashboard-v2-plan.md} +0 -0
  612. /package/docs/{removable-code-analysis.md → archive/removable-code-analysis.md} +0 -0
  613. /package/docs/{competitive-analysis-mcp-agent-mail.md → competitive/MCP_AGENT_MAIL.md} +0 -0
@@ -3,7 +3,9 @@ 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';
8
+ import { exec } from 'child_process';
7
9
  import { fileURLToPath } from 'url';
8
10
  import { SqliteStorageAdapter } from '../storage/sqlite-adapter.js';
9
11
  import { RelayClient } from '../wrapper/client.js';
@@ -11,8 +13,250 @@ import { computeNeedsAttention } from './needs-attention.js';
11
13
  import { computeSystemMetrics, formatPrometheusMetrics } from './metrics.js';
12
14
  import { MultiProjectClient } from '../bridge/multi-project-client.js';
13
15
  import { AgentSpawner } from '../bridge/spawner.js';
16
+ import { listTrajectorySteps, getTrajectoryStatus, getTrajectoryHistory } from '../trajectory/integration.js';
17
+ import { loadTeamsConfig } from '../bridge/teams-config.js';
18
+ import { getMemoryMonitor } from '../resiliency/memory-monitor.js';
19
+ import { detectWorkspacePath } from '../utils/project-namespace.js';
20
+ import { startCLIAuth, getAuthSession, cancelAuthSession, submitAuthCode, completeAuthSession, getSupportedProviders, } from '../daemon/cli-auth.js';
21
+ /**
22
+ * Initialize cloud persistence for session tracking.
23
+ *
24
+ * Activation modes:
25
+ * 1. Local dev: Set RELAY_CLOUD_ENABLED=true and DATABASE_URL
26
+ * 2. Cloud deployment: Plan-based - user must have Pro+ subscription
27
+ * (enforced at cloud API level when linking daemon or enabling workspace)
28
+ *
29
+ * Session persistence (Pro+ feature) enables:
30
+ * - [[SUMMARY]] blocks saved to PostgreSQL
31
+ * - [[SESSION_END]] markers for session tracking
32
+ * - Session recovery and agent handoff
33
+ *
34
+ * @see canUseSessionPersistence in services/planLimits.ts
35
+ */
36
+ async function initCloudPersistence(workspaceId) {
37
+ // Local dev mode: simple env var check
38
+ // Cloud mode: plan check happens at API level (daemon linking, workspace config)
39
+ if (process.env.RELAY_CLOUD_ENABLED !== 'true') {
40
+ return null;
41
+ }
42
+ try {
43
+ // Dynamic import to avoid loading cloud dependencies unless enabled
44
+ const { getDb } = await import('../cloud/db/drizzle.js');
45
+ const { agentSessions, agentSummaries } = await import('../cloud/db/schema.js');
46
+ const { eq } = await import('drizzle-orm');
47
+ const db = getDb();
48
+ console.log('[dashboard] Cloud persistence enabled');
49
+ // Track active sessions per agent with timestamps for TTL cleanup
50
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
51
+ const MAX_SESSIONS = 10000;
52
+ const agentSessionIds = new Map();
53
+ // Track pending session creation to prevent race conditions
54
+ const pendingSessionCreation = new Map();
55
+ // Periodic cleanup of stale sessions (every 5 minutes)
56
+ const cleanupInterval = setInterval(() => {
57
+ const now = Date.now();
58
+ let evicted = 0;
59
+ for (const [name, { lastActivity }] of agentSessionIds.entries()) {
60
+ if (now - lastActivity > SESSION_TTL_MS) {
61
+ agentSessionIds.delete(name);
62
+ evicted++;
63
+ }
64
+ }
65
+ if (evicted > 0) {
66
+ console.log(`[cloud] Evicted ${evicted} stale session entries`);
67
+ }
68
+ }, 5 * 60 * 1000);
69
+ // Don't keep process alive just for cleanup
70
+ cleanupInterval.unref();
71
+ // Helper to get or create session with race protection
72
+ const getOrCreateSession = async (agentName) => {
73
+ // Check cache first
74
+ const cached = agentSessionIds.get(agentName);
75
+ if (cached) {
76
+ return cached.id;
77
+ }
78
+ // Check if creation is already in progress
79
+ const pending = pendingSessionCreation.get(agentName);
80
+ if (pending) {
81
+ return pending;
82
+ }
83
+ // Create session with mutex
84
+ const creationPromise = (async () => {
85
+ try {
86
+ // Double-check cache after acquiring "lock"
87
+ const rechecked = agentSessionIds.get(agentName);
88
+ if (rechecked) {
89
+ return rechecked.id;
90
+ }
91
+ // Enforce max size - evict oldest if needed
92
+ if (agentSessionIds.size >= MAX_SESSIONS) {
93
+ let oldest = null;
94
+ for (const [name, { lastActivity }] of agentSessionIds.entries()) {
95
+ if (!oldest || lastActivity < oldest.time) {
96
+ oldest = { name, time: lastActivity };
97
+ }
98
+ }
99
+ if (oldest) {
100
+ agentSessionIds.delete(oldest.name);
101
+ console.log(`[cloud] Evicted oldest session for ${oldest.name} (max sessions reached)`);
102
+ }
103
+ }
104
+ // Create a new session with null safety
105
+ const result = await db.insert(agentSessions).values({
106
+ workspaceId,
107
+ agentName,
108
+ status: 'active',
109
+ startedAt: new Date(),
110
+ }).returning();
111
+ const session = result[0];
112
+ if (!session) {
113
+ throw new Error(`Failed to create session for agent ${agentName}`);
114
+ }
115
+ // Update cache
116
+ agentSessionIds.set(agentName, { id: session.id, lastActivity: Date.now() });
117
+ return session.id;
118
+ }
119
+ finally {
120
+ pendingSessionCreation.delete(agentName);
121
+ }
122
+ })();
123
+ pendingSessionCreation.set(agentName, creationPromise);
124
+ return creationPromise;
125
+ };
126
+ return {
127
+ onSummary: async (agentName, event) => {
128
+ try {
129
+ // Get or create session with race protection
130
+ const sessionId = await getOrCreateSession(agentName);
131
+ // Update activity timestamp
132
+ agentSessionIds.set(agentName, { id: sessionId, lastActivity: Date.now() });
133
+ // Insert summary
134
+ await db.insert(agentSummaries).values({
135
+ sessionId,
136
+ agentName,
137
+ summary: event.summary,
138
+ createdAt: new Date(),
139
+ });
140
+ console.log(`[cloud] Saved summary for ${agentName}: ${event.summary.currentTask || 'no task'}`);
141
+ }
142
+ catch (err) {
143
+ console.error(`[cloud] Failed to save summary for ${agentName}:`, err);
144
+ }
145
+ },
146
+ onSessionEnd: async (agentName, event) => {
147
+ try {
148
+ const cached = agentSessionIds.get(agentName);
149
+ if (cached) {
150
+ // Update session as ended
151
+ await db.update(agentSessions)
152
+ .set({
153
+ status: 'ended',
154
+ endedAt: new Date(),
155
+ endMarker: event.marker,
156
+ })
157
+ .where(eq(agentSessions.id, cached.id));
158
+ agentSessionIds.delete(agentName);
159
+ console.log(`[cloud] Session ended for ${agentName}: ${event.marker.summary || 'no summary'}`);
160
+ }
161
+ }
162
+ catch (err) {
163
+ console.error(`[cloud] Failed to end session for ${agentName}:`, err);
164
+ }
165
+ },
166
+ destroy: () => {
167
+ clearInterval(cleanupInterval);
168
+ agentSessionIds.clear();
169
+ pendingSessionCreation.clear();
170
+ console.log('[cloud] Cloud persistence handler destroyed');
171
+ },
172
+ };
173
+ }
174
+ catch (err) {
175
+ console.warn('[dashboard] Cloud persistence not available:', err);
176
+ return null;
177
+ }
178
+ }
14
179
  const __filename = fileURLToPath(import.meta.url);
15
180
  const __dirname = path.dirname(__filename);
181
+ /**
182
+ * Search for files in a directory matching a query pattern.
183
+ * Uses a simple recursive search with common ignore patterns.
184
+ */
185
+ async function searchFiles(rootDir, query, limit) {
186
+ const results = [];
187
+ const queryLower = query.toLowerCase();
188
+ // Directories to ignore
189
+ const ignoreDirs = new Set([
190
+ 'node_modules', '.git', 'dist', 'build', '.next', 'coverage',
191
+ '__pycache__', '.venv', 'venv', '.cache', '.turbo', '.vercel',
192
+ '.nuxt', '.output', 'vendor', 'target', '.idea', '.vscode'
193
+ ]);
194
+ // File patterns to ignore
195
+ const ignorePatterns = [
196
+ /\.lock$/,
197
+ /\.log$/,
198
+ /\.min\.(js|css)$/,
199
+ /\.map$/,
200
+ /\.d\.ts$/,
201
+ /\.pyc$/,
202
+ ];
203
+ const shouldIgnore = (name, isDir) => {
204
+ if (isDir)
205
+ return ignoreDirs.has(name);
206
+ return ignorePatterns.some(pattern => pattern.test(name));
207
+ };
208
+ const matchesQuery = (filePath, fileName) => {
209
+ if (!query)
210
+ return true;
211
+ const pathLower = filePath.toLowerCase();
212
+ const nameLower = fileName.toLowerCase();
213
+ // If query contains '/', match against full path
214
+ if (queryLower.includes('/')) {
215
+ return pathLower.includes(queryLower);
216
+ }
217
+ // Otherwise match against file name or path segments
218
+ return nameLower.includes(queryLower) || pathLower.includes(queryLower);
219
+ };
220
+ const searchDir = async (dir, relativePath = '') => {
221
+ if (results.length >= limit)
222
+ return;
223
+ try {
224
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
225
+ // Sort: directories first, then alphabetically
226
+ entries.sort((a, b) => {
227
+ if (a.isDirectory() !== b.isDirectory()) {
228
+ return a.isDirectory() ? -1 : 1;
229
+ }
230
+ return a.name.localeCompare(b.name);
231
+ });
232
+ for (const entry of entries) {
233
+ if (results.length >= limit)
234
+ break;
235
+ const entryPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
236
+ const fullPath = path.join(dir, entry.name);
237
+ if (shouldIgnore(entry.name, entry.isDirectory()))
238
+ continue;
239
+ if (matchesQuery(entryPath, entry.name)) {
240
+ results.push({
241
+ path: entryPath,
242
+ name: entry.name,
243
+ isDirectory: entry.isDirectory(),
244
+ });
245
+ }
246
+ // Recurse into directories
247
+ if (entry.isDirectory() && results.length < limit) {
248
+ await searchDir(fullPath, entryPath);
249
+ }
250
+ }
251
+ }
252
+ catch (err) {
253
+ // Ignore permission errors, etc.
254
+ console.warn(`[searchFiles] Error reading ${dir}:`, err);
255
+ }
256
+ };
257
+ await searchDir(rootDir);
258
+ return results;
259
+ }
16
260
  export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPathArg) {
17
261
  // Handle overloaded signatures
18
262
  const options = typeof portOrOptions === 'number'
@@ -24,9 +268,49 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
24
268
  ? new SqliteStorageAdapter({ dbPath })
25
269
  : undefined;
26
270
  // Initialize spawner if enabled
271
+ // Use detectWorkspacePath to find the actual repo directory in cloud workspaces
272
+ const workspacePath = detectWorkspacePath(projectRoot || dataDir);
273
+ console.log(`[dashboard] Workspace path: ${workspacePath}`);
274
+ // Pass dashboard port to spawner so spawned agents can call spawn/release APIs for nested spawning
27
275
  const spawner = enableSpawner
28
- ? new AgentSpawner(projectRoot || dataDir, tmuxSession)
276
+ ? new AgentSpawner(workspacePath, tmuxSession, port)
29
277
  : undefined;
278
+ // Initialize cloud persistence and memory monitoring if enabled (RELAY_CLOUD_ENABLED=true)
279
+ if (spawner) {
280
+ // Use workspace ID from env or generate from project root
281
+ const workspaceId = process.env.RELAY_WORKSPACE_ID ||
282
+ crypto.createHash('sha256').update(projectRoot || dataDir).digest('hex').slice(0, 36);
283
+ initCloudPersistence(workspaceId).then((cloudHandler) => {
284
+ if (cloudHandler) {
285
+ spawner.setCloudPersistence(cloudHandler);
286
+ }
287
+ }).catch((err) => {
288
+ console.warn('[dashboard] Failed to initialize cloud persistence:', err);
289
+ });
290
+ // Initialize memory monitoring for cloud deployments
291
+ // Memory monitoring is enabled by default when cloud is enabled
292
+ if (process.env.RELAY_CLOUD_ENABLED === 'true' || process.env.RELAY_MEMORY_MONITORING === 'true') {
293
+ try {
294
+ const memoryMonitor = getMemoryMonitor({
295
+ checkIntervalMs: 10000, // Check every 10 seconds
296
+ enableTrendAnalysis: true,
297
+ enableProactiveAlerts: true,
298
+ });
299
+ memoryMonitor.start();
300
+ console.log('[dashboard] Memory monitoring enabled');
301
+ // Register existing workers with memory monitor
302
+ const workers = spawner.getActiveWorkers();
303
+ for (const worker of workers) {
304
+ if (worker.pid) {
305
+ memoryMonitor.register(worker.name, worker.pid);
306
+ }
307
+ }
308
+ }
309
+ catch (err) {
310
+ console.warn('[dashboard] Failed to initialize memory monitoring:', err);
311
+ }
312
+ }
313
+ }
30
314
  process.on('uncaughtException', (err) => {
31
315
  console.error('Uncaught Exception:', err);
32
316
  });
@@ -51,6 +335,45 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
51
335
  skipUTF8Validation: true,
52
336
  maxPayload: 100 * 1024 * 1024
53
337
  });
338
+ const wssLogs = new WebSocketServer({
339
+ noServer: true,
340
+ perMessageDeflate: false,
341
+ skipUTF8Validation: true,
342
+ maxPayload: 100 * 1024 * 1024
343
+ });
344
+ const wssPresence = new WebSocketServer({
345
+ noServer: true,
346
+ perMessageDeflate: false,
347
+ skipUTF8Validation: true,
348
+ maxPayload: 1024 * 1024 // 1MB - presence messages are small
349
+ });
350
+ // Track log subscriptions: agentName -> Set of WebSocket clients
351
+ const logSubscriptions = new Map();
352
+ const onlineUsers = new Map();
353
+ // Validation helpers for presence
354
+ const isValidUsername = (username) => {
355
+ if (typeof username !== 'string')
356
+ return false;
357
+ // Username should be 1-39 chars, alphanumeric with hyphens (GitHub username rules)
358
+ return /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(username);
359
+ };
360
+ const isValidAvatarUrl = (url) => {
361
+ if (url === undefined || url === null)
362
+ return true;
363
+ if (typeof url !== 'string')
364
+ return false;
365
+ // Must be a valid HTTPS URL from GitHub or similar known providers
366
+ try {
367
+ const parsed = new URL(url);
368
+ return parsed.protocol === 'https:' &&
369
+ (parsed.hostname === 'avatars.githubusercontent.com' ||
370
+ parsed.hostname === 'github.com' ||
371
+ parsed.hostname.endsWith('.githubusercontent.com'));
372
+ }
373
+ catch {
374
+ return false;
375
+ }
376
+ };
54
377
  // Manually handle upgrade requests and route to correct WebSocketServer
55
378
  server.on('upgrade', (request, socket, head) => {
56
379
  const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname;
@@ -64,6 +387,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
64
387
  wssBridge.emit('connection', ws, request);
65
388
  });
66
389
  }
390
+ else if (pathname === '/ws/logs' || pathname.startsWith('/ws/logs/')) {
391
+ wssLogs.handleUpgrade(request, socket, head, (ws) => {
392
+ wssLogs.emit('connection', ws, request);
393
+ });
394
+ }
395
+ else if (pathname === '/ws/presence') {
396
+ wssPresence.handleUpgrade(request, socket, head, (ws) => {
397
+ wssPresence.emit('connection', ws, request);
398
+ });
399
+ }
67
400
  else {
68
401
  // Unknown path - destroy socket
69
402
  socket.destroy();
@@ -76,10 +409,70 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
76
409
  wssBridge.on('error', (err) => {
77
410
  console.error('[dashboard] Bridge WebSocket server error:', err);
78
411
  });
412
+ wssLogs.on('error', (err) => {
413
+ console.error('[dashboard] Logs WebSocket server error:', err);
414
+ });
415
+ wssPresence.on('error', (err) => {
416
+ console.error('[dashboard] Presence WebSocket server error:', err);
417
+ });
79
418
  if (storage) {
80
419
  await storage.init();
81
420
  }
82
- app.use(express.json());
421
+ // Increase JSON body limit for base64 image uploads (10MB)
422
+ app.use(express.json({ limit: '10mb' }));
423
+ // Create attachments directory in user's home directory (~/.relay/attachments)
424
+ // This keeps attachments out of source control while still accessible to agents
425
+ const attachmentsDir = path.join(os.homedir(), '.relay', 'attachments');
426
+ if (!fs.existsSync(attachmentsDir)) {
427
+ fs.mkdirSync(attachmentsDir, { recursive: true });
428
+ }
429
+ // Also keep uploads dir for backwards compatibility (URL-based serving)
430
+ const uploadsDir = path.join(dataDir, 'uploads');
431
+ if (!fs.existsSync(uploadsDir)) {
432
+ fs.mkdirSync(uploadsDir, { recursive: true });
433
+ }
434
+ // Auto-evict old attachments (older than 7 days)
435
+ const ATTACHMENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
436
+ const evictOldAttachments = async () => {
437
+ try {
438
+ const files = await fs.promises.readdir(attachmentsDir);
439
+ const now = Date.now();
440
+ let evictedCount = 0;
441
+ for (const file of files) {
442
+ const filePath = path.join(attachmentsDir, file);
443
+ try {
444
+ const stat = await fs.promises.stat(filePath);
445
+ if (stat.isFile() && (now - stat.mtimeMs) > ATTACHMENT_MAX_AGE_MS) {
446
+ await fs.promises.unlink(filePath);
447
+ evictedCount++;
448
+ }
449
+ }
450
+ catch (_err) {
451
+ // Ignore errors for individual files (may have been deleted)
452
+ }
453
+ }
454
+ if (evictedCount > 0) {
455
+ console.log(`[dashboard] Evicted ${evictedCount} old attachment(s)`);
456
+ }
457
+ }
458
+ catch (err) {
459
+ console.error('[dashboard] Failed to evict old attachments:', err);
460
+ }
461
+ };
462
+ // Run eviction on startup and every hour
463
+ evictOldAttachments();
464
+ const evictionInterval = setInterval(evictOldAttachments, 60 * 60 * 1000); // 1 hour
465
+ // Clean up interval on process exit
466
+ process.on('beforeExit', () => {
467
+ clearInterval(evictionInterval);
468
+ });
469
+ // Serve uploaded files statically
470
+ app.use('/uploads', express.static(uploadsDir));
471
+ // Serve attachments from ~/.relay/attachments
472
+ app.use('/attachments', express.static(attachmentsDir));
473
+ // In-memory attachment registry (for current session)
474
+ // Attachments are also stored on disk, so this is just for quick lookups
475
+ const attachmentRegistry = new Map();
83
476
  // Serve dashboard static files at root (built with `next build` in src/dashboard)
84
477
  // __dirname is dist/dashboard-server, dashboard is at ../dashboard/out (relative to dist)
85
478
  // But in source it's at ../dashboard/out (relative to src/dashboard-server)
@@ -99,39 +492,70 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
99
492
  else {
100
493
  console.error('[dashboard] Dashboard not found at:', dashboardDistDir, 'or', dashboardSourceDir);
101
494
  }
102
- // Relay client for sending messages from dashboard
495
+ // Relay clients for sending messages from dashboard
496
+ // Map of senderName -> RelayClient for per-user connections
103
497
  const socketPath = path.join(dataDir, 'relay.sock');
104
- let relayClient;
105
- const connectRelayClient = async () => {
498
+ const relayClients = new Map();
499
+ // Track pending client connections to prevent race conditions
500
+ const pendingConnections = new Map();
501
+ // Get or create a relay client for a specific sender
502
+ const getRelayClient = async (senderName = 'Dashboard') => {
503
+ // Check if we already have a connected client for this sender
504
+ const existing = relayClients.get(senderName);
505
+ if (existing && existing.state === 'READY') {
506
+ return existing;
507
+ }
508
+ // Check if there's already a pending connection for this sender
509
+ const pending = pendingConnections.get(senderName);
510
+ if (pending) {
511
+ return pending;
512
+ }
106
513
  // Only attempt connection if socket exists (daemon is running)
107
514
  if (!fs.existsSync(socketPath)) {
108
515
  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;
516
+ return undefined;
131
517
  }
518
+ // Create connection promise to prevent race conditions
519
+ const connectionPromise = (async () => {
520
+ // Create new client for this sender
521
+ const client = new RelayClient({
522
+ socketPath,
523
+ agentName: senderName,
524
+ cli: 'dashboard',
525
+ reconnect: true,
526
+ maxReconnectAttempts: 5,
527
+ });
528
+ client.onError = (err) => {
529
+ console.error(`[dashboard] Relay client error for ${senderName}:`, err.message);
530
+ };
531
+ client.onStateChange = (state) => {
532
+ console.log(`[dashboard] Relay client for ${senderName} state: ${state}`);
533
+ // Clean up disconnected clients
534
+ if (state === 'DISCONNECTED') {
535
+ relayClients.delete(senderName);
536
+ }
537
+ };
538
+ try {
539
+ await client.connect();
540
+ relayClients.set(senderName, client);
541
+ console.log(`[dashboard] Connected to relay daemon as ${senderName}`);
542
+ return client;
543
+ }
544
+ catch (err) {
545
+ console.error(`[dashboard] Failed to connect to relay daemon as ${senderName}:`, err);
546
+ return undefined;
547
+ }
548
+ finally {
549
+ // Clean up pending connection
550
+ pendingConnections.delete(senderName);
551
+ }
552
+ })();
553
+ // Store the pending connection
554
+ pendingConnections.set(senderName, connectionPromise);
555
+ return connectionPromise;
132
556
  };
133
- // Start relay client connection (non-blocking)
134
- connectRelayClient().catch(() => { });
557
+ // Start default relay client connection (non-blocking)
558
+ getRelayClient('Dashboard').catch(() => { });
135
559
  // Bridge client for cross-project messaging
136
560
  let bridgeClient;
137
561
  let bridgeClientConnecting = false;
@@ -188,26 +612,132 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
188
612
  };
189
613
  // Start bridge client connection (non-blocking)
190
614
  connectBridgeClient().catch(() => { });
615
+ // Helper to check if an agent is online (seen within heartbeat timeout window)
616
+ // Uses 30 second threshold to align with heartbeat timeout (5s * 6 multiplier)
617
+ const isAgentOnline = (agentName) => {
618
+ if (agentName === '*')
619
+ return true; // Broadcast always allowed
620
+ const agentsPath = path.join(teamDir, 'agents.json');
621
+ if (!fs.existsSync(agentsPath))
622
+ return false;
623
+ try {
624
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
625
+ const agent = data.agents?.find((a) => a.name === agentName);
626
+ if (!agent || !agent.lastSeen)
627
+ return false;
628
+ const thirtySecondsAgo = Date.now() - 30 * 1000;
629
+ return new Date(agent.lastSeen).getTime() > thirtySecondsAgo;
630
+ }
631
+ catch {
632
+ return false;
633
+ }
634
+ };
635
+ // Helper to get team members from teams.json, agents.json, and spawner's active workers
636
+ const getTeamMembers = (teamName) => {
637
+ const members = new Set();
638
+ // Check teams.json first - this is the source of truth for team definitions
639
+ const teamsConfig = loadTeamsConfig(projectRoot || dataDir);
640
+ if (teamsConfig && teamsConfig.team === teamName) {
641
+ for (const agent of teamsConfig.agents) {
642
+ members.add(agent.name);
643
+ }
644
+ }
645
+ // Check spawner's active workers (they have accurate team info for spawned agents)
646
+ if (spawner) {
647
+ const activeWorkers = spawner.getActiveWorkers();
648
+ for (const worker of activeWorkers) {
649
+ if (worker.team === teamName) {
650
+ members.add(worker.name);
651
+ }
652
+ }
653
+ }
654
+ // Also check agents.json for persisted team info
655
+ const agentsPath = path.join(teamDir, 'agents.json');
656
+ if (fs.existsSync(agentsPath)) {
657
+ try {
658
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
659
+ for (const agent of (data.agents || [])) {
660
+ if (agent.team === teamName) {
661
+ members.add(agent.name);
662
+ }
663
+ }
664
+ }
665
+ catch {
666
+ // Ignore parse errors
667
+ }
668
+ }
669
+ return Array.from(members);
670
+ };
191
671
  // API endpoint to send messages
192
672
  app.post('/api/send', async (req, res) => {
193
- const { to, message, thread } = req.body;
673
+ const { to, message, thread, attachments: attachmentIds, from: senderName } = req.body;
194
674
  if (!to || !message) {
195
675
  return res.status(400).json({ error: 'Missing "to" or "message" field' });
196
676
  }
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' });
677
+ // Check if this is a team mention (team:teamName)
678
+ const teamMatch = to.match(/^team:(.+)$/);
679
+ let targets;
680
+ if (teamMatch) {
681
+ const teamName = teamMatch[1];
682
+ const members = getTeamMembers(teamName);
683
+ if (members.length === 0) {
684
+ return res.status(404).json({ error: `No agents found in team "${teamName}"` });
685
+ }
686
+ // Filter to only online members
687
+ targets = members.filter(isAgentOnline);
688
+ if (targets.length === 0) {
689
+ return res.status(404).json({ error: `No online agents in team "${teamName}"` });
690
+ }
691
+ }
692
+ else {
693
+ // Fail fast if target agent is offline (except broadcasts)
694
+ if (to !== '*' && !isAgentOnline(to)) {
695
+ return res.status(404).json({ error: `Agent "${to}" is not online` });
202
696
  }
697
+ targets = [to];
698
+ }
699
+ // Get or create relay client for this sender (defaults to 'Dashboard' for non-cloud mode)
700
+ const relayClient = await getRelayClient(senderName || 'Dashboard');
701
+ if (!relayClient || relayClient.state !== 'READY') {
702
+ return res.status(503).json({ error: 'Relay daemon not connected' });
203
703
  }
204
704
  try {
205
- const sent = relayClient.sendMessage(to, message, 'message', undefined, thread);
206
- if (sent) {
207
- res.json({ success: true });
705
+ // Resolve attachments if provided
706
+ let attachments;
707
+ if (attachmentIds && Array.isArray(attachmentIds) && attachmentIds.length > 0) {
708
+ attachments = [];
709
+ for (const id of attachmentIds) {
710
+ const attachment = attachmentRegistry.get(id);
711
+ if (attachment) {
712
+ attachments.push(attachment);
713
+ }
714
+ }
715
+ }
716
+ // Include attachments and channel context in the message data field
717
+ // For broadcasts (to='*'), include channel: 'general' so replies can be routed back
718
+ const isBroadcast = targets.length === 1 && targets[0] === '*';
719
+ const messageData = {};
720
+ if (attachments && attachments.length > 0) {
721
+ messageData.attachments = attachments;
722
+ }
723
+ if (isBroadcast) {
724
+ messageData.channel = 'general';
725
+ }
726
+ const hasMessageData = Object.keys(messageData).length > 0;
727
+ // Send to all targets (single agent, team members, or broadcast)
728
+ let allSent = true;
729
+ for (const target of targets) {
730
+ const sent = relayClient.sendMessage(target, message, 'message', hasMessageData ? messageData : undefined, thread);
731
+ if (!sent) {
732
+ allSent = false;
733
+ console.error(`[dashboard] Failed to send message to ${target}`);
734
+ }
735
+ }
736
+ if (allSent) {
737
+ res.json({ success: true, sentTo: targets.length > 1 ? targets : targets[0] });
208
738
  }
209
739
  else {
210
- res.status(500).json({ error: 'Failed to send message' });
740
+ res.status(500).json({ error: 'Failed to send message to some recipients' });
211
741
  }
212
742
  }
213
743
  catch (err) {
@@ -242,6 +772,91 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
242
772
  res.status(500).json({ error: 'Failed to send bridge message' });
243
773
  }
244
774
  });
775
+ // API endpoint to upload attachments (images/screenshots)
776
+ app.post('/api/upload', async (req, res) => {
777
+ const { filename, mimeType, data } = req.body;
778
+ // Validate required fields
779
+ if (!filename || !mimeType || !data) {
780
+ return res.status(400).json({
781
+ success: false,
782
+ error: 'Missing required fields: filename, mimeType, data',
783
+ });
784
+ }
785
+ // Validate mime type (only allow images for now)
786
+ const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
787
+ if (!allowedTypes.includes(mimeType)) {
788
+ return res.status(400).json({
789
+ success: false,
790
+ error: `Invalid file type. Allowed types: ${allowedTypes.join(', ')}`,
791
+ });
792
+ }
793
+ try {
794
+ // Decode base64 data
795
+ const base64Data = data.replace(/^data:[^;]+;base64,/, '');
796
+ const buffer = Buffer.from(base64Data, 'base64');
797
+ // Generate unique ID and filename for the attachment
798
+ const attachmentId = crypto.randomUUID();
799
+ const timestamp = Date.now();
800
+ const ext = mimeType.split('/')[1].replace('svg+xml', 'svg');
801
+ // Use format: {messageId}-{timestamp}.{ext} for unique, identifiable filenames
802
+ const safeFilename = `${attachmentId.substring(0, 8)}-${timestamp}.${ext}`;
803
+ // Save to ~/.relay/attachments/ directory for agents to access
804
+ const attachmentFilePath = path.join(attachmentsDir, safeFilename);
805
+ fs.writeFileSync(attachmentFilePath, buffer);
806
+ // Create attachment record with file path for agents
807
+ const attachment = {
808
+ id: attachmentId,
809
+ filename: filename,
810
+ mimeType: mimeType,
811
+ size: buffer.length,
812
+ url: `/attachments/${safeFilename}`,
813
+ // Include absolute file path so agents can read the file directly
814
+ filePath: attachmentFilePath,
815
+ // Include base64 data for agents that can't access the file
816
+ data: data,
817
+ };
818
+ // Store in registry for lookup when sending messages
819
+ attachmentRegistry.set(attachmentId, attachment);
820
+ console.log(`[dashboard] Uploaded attachment: ${filename} (${buffer.length} bytes) -> ${attachmentFilePath}`);
821
+ res.json({
822
+ success: true,
823
+ attachment: {
824
+ id: attachment.id,
825
+ filename: attachment.filename,
826
+ mimeType: attachment.mimeType,
827
+ size: attachment.size,
828
+ url: attachment.url,
829
+ filePath: attachment.filePath,
830
+ },
831
+ });
832
+ }
833
+ catch (err) {
834
+ console.error('[dashboard] Upload failed:', err);
835
+ res.status(500).json({
836
+ success: false,
837
+ error: 'Failed to upload file',
838
+ });
839
+ }
840
+ });
841
+ // API endpoint to get attachment by ID
842
+ app.get('/api/attachment/:id', (req, res) => {
843
+ const { id } = req.params;
844
+ const attachment = attachmentRegistry.get(id);
845
+ if (!attachment) {
846
+ return res.status(404).json({ error: 'Attachment not found' });
847
+ }
848
+ res.json({
849
+ success: true,
850
+ attachment: {
851
+ id: attachment.id,
852
+ filename: attachment.filename,
853
+ mimeType: attachment.mimeType,
854
+ size: attachment.size,
855
+ url: attachment.url,
856
+ filePath: attachment.filePath,
857
+ },
858
+ });
859
+ });
245
860
  const getTeamData = () => {
246
861
  // Try team.json first (file-based team mode)
247
862
  const teamPath = path.join(teamDir, 'team.json');
@@ -266,6 +881,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
266
881
  cli: a.cli ?? 'Unknown',
267
882
  lastSeen: a.lastSeen ?? a.connectedAt,
268
883
  lastActive: a.lastSeen ?? a.connectedAt,
884
+ team: a.team,
269
885
  })),
270
886
  };
271
887
  }
@@ -315,16 +931,40 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
315
931
  return [];
316
932
  }
317
933
  };
934
+ // Helper to check if an agent name is internal/system (should be hidden from UI)
935
+ // Convention: agent names starting with __ are internal (e.g., __spawner__, __DashboardBridge__)
936
+ const isInternalAgent = (name) => {
937
+ return name.startsWith('__');
938
+ };
318
939
  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
- }));
940
+ // Filter out messages from/to internal system agents (e.g., __spawner__)
941
+ .filter((row) => !isInternalAgent(row.from) && !isInternalAgent(row.to))
942
+ .map((row) => {
943
+ // Extract attachments and channel from the data field if present
944
+ let attachments;
945
+ let channel;
946
+ if (row.data && typeof row.data === 'object') {
947
+ if ('attachments' in row.data) {
948
+ attachments = row.data.attachments;
949
+ }
950
+ if ('channel' in row.data) {
951
+ channel = row.data.channel;
952
+ }
953
+ }
954
+ return {
955
+ from: row.from,
956
+ to: row.to,
957
+ content: row.body,
958
+ timestamp: new Date(row.ts).toISOString(),
959
+ id: row.id,
960
+ thread: row.thread,
961
+ isBroadcast: row.is_broadcast,
962
+ replyCount: row.replyCount,
963
+ status: row.status,
964
+ attachments,
965
+ channel,
966
+ };
967
+ });
328
968
  const getMessages = async (agents) => {
329
969
  if (storage) {
330
970
  const rows = await storage.getMessages({ limit: 100, order: 'desc' });
@@ -397,6 +1037,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
397
1037
  lastSeen: a.lastSeen,
398
1038
  lastActive: a.lastActive,
399
1039
  needsAttention: false,
1040
+ team: a.team,
400
1041
  });
401
1042
  });
402
1043
  // Update inbox counts if fallback mode; if storage, count messages addressed to agent
@@ -443,7 +1084,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
443
1084
  }
444
1085
  });
445
1086
  // Read processing state from daemon
446
- const processingStatePath = path.join(dataDir, 'processing-state.json');
1087
+ const processingStatePath = path.join(teamDir, 'processing-state.json');
447
1088
  if (fs.existsSync(processingStatePath)) {
448
1089
  try {
449
1090
  const processingData = JSON.parse(fs.readFileSync(processingStatePath, 'utf-8'));
@@ -473,23 +1114,44 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
473
1114
  }
474
1115
  }
475
1116
  }
1117
+ // Set team from teams.json for agents that don't have a team yet
1118
+ // This ensures agents defined in teams.json are associated with their team
1119
+ // even if they weren't spawned via auto-spawn
1120
+ const teamsConfig = loadTeamsConfig(projectRoot || dataDir);
1121
+ if (teamsConfig) {
1122
+ for (const teamAgent of teamsConfig.agents) {
1123
+ const agent = agentsMap.get(teamAgent.name);
1124
+ if (agent && !agent.team) {
1125
+ agent.team = teamsConfig.team;
1126
+ }
1127
+ }
1128
+ }
476
1129
  // Fetch sessions and summaries in parallel
477
1130
  const [sessions, summaries] = await Promise.all([
478
1131
  getRecentSessions(),
479
1132
  getAgentSummaries(),
480
1133
  ]);
481
- // Filter agents:
1134
+ // Filter and separate agents from human users:
482
1135
  // 1. Exclude "Dashboard" (internal agent, not a real team member)
483
- // 2. Exclude offline agents (no lastSeen or lastSeen > 5 minutes ago)
1136
+ // 2. Exclude offline agents (no lastSeen or lastSeen > threshold)
1137
+ // 3. Exclude agents without a known CLI (these are improperly registered or stale)
1138
+ // 4. Separate human users (cli === 'dashboard') from AI agents
484
1139
  const now = Date.now();
485
- const OFFLINE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
486
- const filteredAgents = Array.from(agentsMap.values()).filter(agent => {
1140
+ // 30 seconds - aligns with heartbeat timeout (5s heartbeat * 6 multiplier = 30s)
1141
+ // This ensures agents disappear quickly after they stop responding to heartbeats
1142
+ const OFFLINE_THRESHOLD_MS = 30 * 1000;
1143
+ // First pass: filter out invalid/offline entries
1144
+ const validEntries = Array.from(agentsMap.values())
1145
+ .filter(agent => {
487
1146
  // Exclude Dashboard
488
1147
  if (agent.name === 'Dashboard')
489
1148
  return false;
490
1149
  // Exclude agents starting with __ (internal/system agents)
491
1150
  if (agent.name.startsWith('__'))
492
1151
  return false;
1152
+ // Exclude agents without a proper CLI (improperly registered or stale)
1153
+ if (!agent.cli || agent.cli === 'Unknown')
1154
+ return false;
493
1155
  // Exclude offline agents (no lastSeen or too old)
494
1156
  if (!agent.lastSeen)
495
1157
  return false;
@@ -498,8 +1160,22 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
498
1160
  return false;
499
1161
  return true;
500
1162
  });
1163
+ // Separate AI agents from human users
1164
+ const filteredAgents = validEntries
1165
+ .filter(agent => agent.cli !== 'dashboard')
1166
+ .map(agent => ({
1167
+ ...agent,
1168
+ isHuman: false,
1169
+ }));
1170
+ const humanUsers = validEntries
1171
+ .filter(agent => agent.cli === 'dashboard')
1172
+ .map(agent => ({
1173
+ ...agent,
1174
+ isHuman: true,
1175
+ }));
501
1176
  return {
502
1177
  agents: filteredAgents,
1178
+ users: humanUsers,
503
1179
  messages: allMessages,
504
1180
  activity: allMessages, // For now, activity log is just the message log
505
1181
  sessions,
@@ -558,13 +1234,13 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
558
1234
  try {
559
1235
  const agentsData = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
560
1236
  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;
1237
+ // Filter to only show online agents (seen within 30 seconds - aligns with heartbeat timeout)
1238
+ const thirtySecondsAgo = Date.now() - 30 * 1000;
563
1239
  project.agents = agentsData.agents
564
1240
  .filter((a) => {
565
1241
  if (!a.lastSeen)
566
1242
  return false;
567
- return new Date(a.lastSeen).getTime() > fiveMinutesAgo;
1243
+ return new Date(a.lastSeen).getTime() > thirtySecondsAgo;
568
1244
  })
569
1245
  .map((a) => ({
570
1246
  name: a.name,
@@ -674,81 +1350,896 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
674
1350
  console.log('[dashboard] Bridge WebSocket client disconnected, code:', code, 'reason:', reason?.toString() || 'none');
675
1351
  });
676
1352
  });
677
- app.get('/api/data', (req, res) => {
678
- getAllData().then((data) => res.json(data)).catch((err) => {
679
- console.error('Failed to fetch dashboard data', err);
680
- res.status(500).json({ error: 'Failed to load data' });
1353
+ // Track alive status for ping/pong keepalive on log connections
1354
+ const logClientAlive = new WeakMap();
1355
+ // Ping interval for log WebSocket connections (30 seconds)
1356
+ // This prevents TCP/proxy timeouts from killing idle connections
1357
+ const LOG_PING_INTERVAL_MS = 30000;
1358
+ const logPingInterval = setInterval(() => {
1359
+ wssLogs.clients.forEach((ws) => {
1360
+ if (logClientAlive.get(ws) === false) {
1361
+ // Client didn't respond to last ping - close gracefully
1362
+ console.log('[dashboard] Logs WebSocket client unresponsive, closing gracefully');
1363
+ ws.close(1000, 'unresponsive');
1364
+ return;
1365
+ }
1366
+ // Mark as not alive until we get a pong
1367
+ logClientAlive.set(ws, false);
1368
+ ws.ping();
681
1369
  });
1370
+ }, LOG_PING_INTERVAL_MS);
1371
+ // Clean up ping interval on server close
1372
+ wssLogs.on('close', () => {
1373
+ clearInterval(logPingInterval);
682
1374
  });
683
- // ===== Metrics API =====
684
- /**
685
- * GET /api/metrics - JSON format metrics for dashboard
686
- */
687
- app.get('/api/metrics', async (req, res) => {
688
- try {
689
- // Read agent registry for message counts
1375
+ // Handle logs WebSocket connections for live log streaming
1376
+ wssLogs.on('connection', (ws, req) => {
1377
+ console.log('[dashboard] Logs WebSocket client connected');
1378
+ const clientSubscriptions = new Set();
1379
+ // Mark client as alive initially
1380
+ logClientAlive.set(ws, true);
1381
+ // Handle pong responses (keep connection alive)
1382
+ ws.on('pong', () => {
1383
+ logClientAlive.set(ws, true);
1384
+ });
1385
+ // Helper to check if agent is daemon-connected (from agents.json)
1386
+ const isDaemonConnected = (agentName) => {
690
1387
  const agentsPath = path.join(teamDir, 'agents.json');
691
- let agentRecords = [];
692
- if (fs.existsSync(agentsPath)) {
1388
+ if (!fs.existsSync(agentsPath))
1389
+ return false;
1390
+ try {
693
1391
  const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
694
- agentRecords = (data.agents || []).map((a) => ({
695
- name: a.name,
696
- messagesSent: a.messagesSent ?? 0,
697
- messagesReceived: a.messagesReceived ?? 0,
698
- firstSeen: a.firstSeen ?? new Date().toISOString(),
699
- lastSeen: a.lastSeen ?? new Date().toISOString(),
1392
+ return data.agents?.some((a) => a.name === agentName) ?? false;
1393
+ }
1394
+ catch {
1395
+ return false;
1396
+ }
1397
+ };
1398
+ // Helper to subscribe to an agent
1399
+ const subscribeToAgent = (agentName) => {
1400
+ const isSpawned = spawner?.hasWorker(agentName) ?? false;
1401
+ const isDaemon = isDaemonConnected(agentName);
1402
+ // Check if agent exists (either spawned or daemon-connected)
1403
+ if (!isSpawned && !isDaemon) {
1404
+ ws.send(JSON.stringify({
1405
+ type: 'error',
1406
+ agent: agentName,
1407
+ error: `Agent ${agentName} not found`,
700
1408
  }));
1409
+ // Close with custom code 4404 to signal "agent not found" - client should not reconnect
1410
+ ws.close(4404, 'Agent not found');
1411
+ return false;
701
1412
  }
702
- // Get messages for throughput calculation
703
- const team = getTeamData();
704
- const messages = team ? await getMessages(team.agents) : [];
705
- // Get session data for lifecycle metrics
706
- const sessions = storage?.getSessions
707
- ? await storage.getSessions({ limit: 100 })
708
- : [];
709
- const metrics = computeSystemMetrics(agentRecords, messages, sessions);
710
- res.json(metrics);
711
- }
712
- catch (err) {
713
- console.error('Failed to compute metrics', err);
714
- res.status(500).json({ error: 'Failed to compute metrics' });
715
- }
716
- });
717
- /**
718
- * GET /api/metrics/prometheus - Prometheus exposition format
719
- */
720
- app.get('/api/metrics/prometheus', async (req, res) => {
721
- try {
722
- // Read agent registry for message counts
723
- const agentsPath = path.join(teamDir, 'agents.json');
724
- let agentRecords = [];
725
- if (fs.existsSync(agentsPath)) {
726
- const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
727
- agentRecords = (data.agents || []).map((a) => ({
728
- name: a.name,
729
- messagesSent: a.messagesSent ?? 0,
730
- messagesReceived: a.messagesReceived ?? 0,
731
- firstSeen: a.firstSeen ?? new Date().toISOString(),
732
- lastSeen: a.lastSeen ?? new Date().toISOString(),
1413
+ // Add to subscriptions
1414
+ clientSubscriptions.add(agentName);
1415
+ if (!logSubscriptions.has(agentName)) {
1416
+ logSubscriptions.set(agentName, new Set());
1417
+ }
1418
+ logSubscriptions.get(agentName).add(ws);
1419
+ console.log(`[dashboard] Client subscribed to logs for: ${agentName} (spawned: ${isSpawned}, daemon: ${isDaemon})`);
1420
+ if (isSpawned && spawner) {
1421
+ // Send initial log history for spawned agents (5000 lines to match xterm scrollback capacity)
1422
+ const lines = spawner.getWorkerOutput(agentName, 5000);
1423
+ ws.send(JSON.stringify({
1424
+ type: 'history',
1425
+ agent: agentName,
1426
+ lines: lines || [],
733
1427
  }));
734
1428
  }
735
- // Get messages for throughput calculation
736
- const team = getTeamData();
737
- const messages = team ? await getMessages(team.agents) : [];
738
- // Get session data for lifecycle metrics
739
- const sessions = storage?.getSessions
740
- ? await storage.getSessions({ limit: 100 })
741
- : [];
742
- const metrics = computeSystemMetrics(agentRecords, messages, sessions);
743
- const prometheusOutput = formatPrometheusMetrics(metrics);
744
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
745
- res.send(prometheusOutput);
746
- }
1429
+ else {
1430
+ // For daemon-connected agents, explain that PTY output isn't available
1431
+ ws.send(JSON.stringify({
1432
+ type: 'history',
1433
+ agent: agentName,
1434
+ lines: [`[${agentName} is a daemon-connected agent - PTY output not available. Showing relay messages only.]`],
1435
+ }));
1436
+ }
1437
+ ws.send(JSON.stringify({
1438
+ type: 'subscribed',
1439
+ agent: agentName,
1440
+ }));
1441
+ return true;
1442
+ };
1443
+ // Check if agent name is in URL path: /ws/logs/:agentName
1444
+ const pathname = new URL(req.url || '', `http://${req.headers.host}`).pathname;
1445
+ const pathMatch = pathname.match(/^\/ws\/logs\/(.+)$/);
1446
+ if (pathMatch) {
1447
+ const agentName = decodeURIComponent(pathMatch[1]);
1448
+ subscribeToAgent(agentName);
1449
+ }
1450
+ ws.on('message', (data) => {
1451
+ try {
1452
+ const msg = JSON.parse(data.toString());
1453
+ // Subscribe to agent logs
1454
+ if (msg.subscribe && typeof msg.subscribe === 'string') {
1455
+ subscribeToAgent(msg.subscribe);
1456
+ }
1457
+ // Unsubscribe from agent logs
1458
+ if (msg.unsubscribe && typeof msg.unsubscribe === 'string') {
1459
+ const agentName = msg.unsubscribe;
1460
+ clientSubscriptions.delete(agentName);
1461
+ logSubscriptions.get(agentName)?.delete(ws);
1462
+ console.log(`[dashboard] Client unsubscribed from logs for: ${agentName}`);
1463
+ ws.send(JSON.stringify({
1464
+ type: 'unsubscribed',
1465
+ agent: agentName,
1466
+ }));
1467
+ }
1468
+ }
1469
+ catch (err) {
1470
+ console.error('[dashboard] Invalid logs WebSocket message:', err);
1471
+ }
1472
+ });
1473
+ ws.on('error', (err) => {
1474
+ console.error('[dashboard] Logs WebSocket client error:', err);
1475
+ });
1476
+ ws.on('close', (code, reason) => {
1477
+ // Clean up subscriptions on disconnect
1478
+ for (const agentName of clientSubscriptions) {
1479
+ logSubscriptions.get(agentName)?.delete(ws);
1480
+ }
1481
+ const reasonStr = reason?.toString() || 'no reason';
1482
+ console.log(`[dashboard] Logs WebSocket client disconnected (code: ${code}, reason: ${reasonStr})`);
1483
+ });
1484
+ });
1485
+ // Deduplication for log output - prevent same content from being broadcast multiple times
1486
+ // Key: agentName -> Set of recent content hashes (rolling window)
1487
+ const recentLogHashes = new Map();
1488
+ const MAX_LOG_HASH_WINDOW = 50; // Keep last 50 hashes per agent
1489
+ // Simple hash function for log dedup
1490
+ const hashLogContent = (content) => {
1491
+ // Normalize whitespace and create a simple hash
1492
+ const normalized = content.replace(/\s+/g, ' ').trim().slice(0, 200);
1493
+ let hash = 0;
1494
+ for (let i = 0; i < normalized.length; i++) {
1495
+ const char = normalized.charCodeAt(i);
1496
+ hash = ((hash << 5) - hash) + char;
1497
+ hash = hash & hash;
1498
+ }
1499
+ return hash.toString(36);
1500
+ };
1501
+ // Function to broadcast log output to subscribed clients
1502
+ const broadcastLogOutput = (agentName, output) => {
1503
+ const clients = logSubscriptions.get(agentName);
1504
+ if (!clients || clients.size === 0)
1505
+ return;
1506
+ // Skip empty or whitespace-only output
1507
+ const trimmed = output.trim();
1508
+ if (!trimmed)
1509
+ return;
1510
+ // Dedup: Check if we've recently broadcast this content
1511
+ const hash = hashLogContent(output);
1512
+ let agentHashes = recentLogHashes.get(agentName);
1513
+ if (!agentHashes) {
1514
+ agentHashes = new Set();
1515
+ recentLogHashes.set(agentName, agentHashes);
1516
+ }
1517
+ if (agentHashes.has(hash)) {
1518
+ // Already broadcast this content recently, skip
1519
+ return;
1520
+ }
1521
+ // Add to rolling window
1522
+ agentHashes.add(hash);
1523
+ if (agentHashes.size > MAX_LOG_HASH_WINDOW) {
1524
+ // Remove oldest entry (first in Set iteration order)
1525
+ const oldest = agentHashes.values().next().value;
1526
+ if (oldest !== undefined) {
1527
+ agentHashes.delete(oldest);
1528
+ }
1529
+ }
1530
+ const payload = JSON.stringify({
1531
+ type: 'output',
1532
+ agent: agentName,
1533
+ data: output,
1534
+ timestamp: new Date().toISOString(),
1535
+ });
1536
+ for (const client of clients) {
1537
+ if (client.readyState === WebSocket.OPEN) {
1538
+ client.send(payload);
1539
+ }
1540
+ }
1541
+ };
1542
+ // Expose broadcastLogOutput for PTY wrappers to call
1543
+ global.__broadcastLogOutput = broadcastLogOutput;
1544
+ // ===== Presence WebSocket Handler =====
1545
+ // Helper to broadcast to all presence clients
1546
+ const broadcastPresence = (message, exclude) => {
1547
+ const payload = JSON.stringify(message);
1548
+ wssPresence.clients.forEach((client) => {
1549
+ if (client !== exclude && client.readyState === WebSocket.OPEN) {
1550
+ client.send(payload);
1551
+ }
1552
+ });
1553
+ };
1554
+ // Helper to get online users list (without ws references)
1555
+ const getOnlineUsersList = () => {
1556
+ return Array.from(onlineUsers.values()).map((state) => state.info);
1557
+ };
1558
+ // Heartbeat to detect dead connections (30 seconds)
1559
+ const PRESENCE_HEARTBEAT_INTERVAL = 30000;
1560
+ const presenceHealth = new WeakMap();
1561
+ const presenceHeartbeat = setInterval(() => {
1562
+ wssPresence.clients.forEach((ws) => {
1563
+ const health = presenceHealth.get(ws);
1564
+ if (!health) {
1565
+ presenceHealth.set(ws, { isAlive: true });
1566
+ return;
1567
+ }
1568
+ if (!health.isAlive) {
1569
+ ws.terminate();
1570
+ return;
1571
+ }
1572
+ health.isAlive = false;
1573
+ ws.ping();
1574
+ });
1575
+ }, PRESENCE_HEARTBEAT_INTERVAL);
1576
+ wssPresence.on('close', () => {
1577
+ clearInterval(presenceHeartbeat);
1578
+ });
1579
+ wssPresence.on('connection', (ws) => {
1580
+ // Initialize health tracking (no log - too noisy)
1581
+ presenceHealth.set(ws, { isAlive: true });
1582
+ ws.on('pong', () => {
1583
+ const health = presenceHealth.get(ws);
1584
+ if (health)
1585
+ health.isAlive = true;
1586
+ });
1587
+ let clientUsername;
1588
+ ws.on('message', (data) => {
1589
+ try {
1590
+ const msg = JSON.parse(data.toString());
1591
+ if (msg.type === 'presence') {
1592
+ if (msg.action === 'join' && msg.user?.username) {
1593
+ const username = msg.user.username;
1594
+ const avatarUrl = msg.user.avatarUrl;
1595
+ // Validate inputs
1596
+ if (!isValidUsername(username)) {
1597
+ console.warn(`[dashboard] Invalid username rejected: ${username}`);
1598
+ return;
1599
+ }
1600
+ if (!isValidAvatarUrl(avatarUrl)) {
1601
+ console.warn(`[dashboard] Invalid avatar URL rejected for user ${username}`);
1602
+ return;
1603
+ }
1604
+ clientUsername = username;
1605
+ const now = new Date().toISOString();
1606
+ // Check if user already has connections (multi-tab support)
1607
+ const existing = onlineUsers.get(username);
1608
+ if (existing) {
1609
+ // Add this connection to existing user
1610
+ existing.connections.add(ws);
1611
+ existing.info.lastSeen = now;
1612
+ // Only log at milestones to reduce noise
1613
+ const count = existing.connections.size;
1614
+ if (count === 2 || count === 5 || count === 10 || count % 50 === 0) {
1615
+ console.log(`[dashboard] User ${username} has ${count} connections`);
1616
+ }
1617
+ }
1618
+ else {
1619
+ // New user - create presence state
1620
+ onlineUsers.set(username, {
1621
+ info: {
1622
+ username,
1623
+ avatarUrl,
1624
+ connectedAt: now,
1625
+ lastSeen: now,
1626
+ },
1627
+ connections: new Set([ws]),
1628
+ });
1629
+ console.log(`[dashboard] User ${username} came online`);
1630
+ // Broadcast join to all other clients (only for truly new users)
1631
+ broadcastPresence({
1632
+ type: 'presence_join',
1633
+ user: {
1634
+ username,
1635
+ avatarUrl,
1636
+ connectedAt: now,
1637
+ lastSeen: now,
1638
+ },
1639
+ }, ws);
1640
+ }
1641
+ // Send current online users list to the new client
1642
+ ws.send(JSON.stringify({
1643
+ type: 'presence_list',
1644
+ users: getOnlineUsersList(),
1645
+ }));
1646
+ }
1647
+ else if (msg.action === 'leave') {
1648
+ // Security: Only allow leaving your own username
1649
+ // Must have authenticated first
1650
+ if (!clientUsername) {
1651
+ console.warn(`[dashboard] Security: Unauthenticated leave attempt`);
1652
+ return;
1653
+ }
1654
+ if (msg.username !== clientUsername) {
1655
+ console.warn(`[dashboard] Security: User ${clientUsername} tried to remove ${msg.username}`);
1656
+ return;
1657
+ }
1658
+ // Remove this connection from the user's set
1659
+ const username = clientUsername; // Narrow type for TypeScript
1660
+ const userState = onlineUsers.get(username);
1661
+ if (userState) {
1662
+ userState.connections.delete(ws);
1663
+ // Only broadcast leave if no more connections
1664
+ if (userState.connections.size === 0) {
1665
+ onlineUsers.delete(username);
1666
+ console.log(`[dashboard] User ${username} went offline`);
1667
+ broadcastPresence({
1668
+ type: 'presence_leave',
1669
+ username,
1670
+ });
1671
+ }
1672
+ else {
1673
+ console.log(`[dashboard] User ${username} closed tab (${userState.connections.size} remaining)`);
1674
+ }
1675
+ }
1676
+ }
1677
+ }
1678
+ else if (msg.type === 'typing') {
1679
+ // Must have authenticated first
1680
+ if (!clientUsername) {
1681
+ console.warn(`[dashboard] Security: Unauthenticated typing attempt`);
1682
+ return;
1683
+ }
1684
+ // Validate typing message comes from authenticated user
1685
+ if (msg.username !== clientUsername) {
1686
+ console.warn(`[dashboard] Security: Typing message username mismatch`);
1687
+ return;
1688
+ }
1689
+ // Update last seen
1690
+ const username = clientUsername; // Narrow type for TypeScript
1691
+ const userState = onlineUsers.get(username);
1692
+ if (userState) {
1693
+ userState.info.lastSeen = new Date().toISOString();
1694
+ }
1695
+ // Broadcast typing indicator to all other clients
1696
+ broadcastPresence({
1697
+ type: 'typing',
1698
+ username,
1699
+ avatarUrl: userState?.info.avatarUrl,
1700
+ isTyping: msg.isTyping,
1701
+ }, ws);
1702
+ }
1703
+ }
1704
+ catch (err) {
1705
+ console.error('[dashboard] Invalid presence message:', err);
1706
+ }
1707
+ });
1708
+ ws.on('error', (err) => {
1709
+ console.error('[dashboard] Presence WebSocket client error:', err);
1710
+ });
1711
+ ws.on('close', () => {
1712
+ // Clean up on disconnect with multi-tab support
1713
+ if (clientUsername) {
1714
+ const userState = onlineUsers.get(clientUsername);
1715
+ if (userState) {
1716
+ userState.connections.delete(ws);
1717
+ // Only broadcast leave if no more connections
1718
+ if (userState.connections.size === 0) {
1719
+ onlineUsers.delete(clientUsername);
1720
+ console.log(`[dashboard] User ${clientUsername} disconnected`);
1721
+ broadcastPresence({
1722
+ type: 'presence_leave',
1723
+ username: clientUsername,
1724
+ });
1725
+ }
1726
+ else {
1727
+ console.log(`[dashboard] User ${clientUsername} closed connection (${userState.connections.size} remaining)`);
1728
+ }
1729
+ }
1730
+ }
1731
+ });
1732
+ });
1733
+ app.get('/api/data', (req, res) => {
1734
+ getAllData().then((data) => res.json(data)).catch((err) => {
1735
+ console.error('Failed to fetch dashboard data', err);
1736
+ res.status(500).json({ error: 'Failed to load data' });
1737
+ });
1738
+ });
1739
+ // ===== Health Check API =====
1740
+ /**
1741
+ * GET /health - Health check endpoint for monitoring
1742
+ * Returns 200 if the daemon is healthy
1743
+ */
1744
+ app.get('/health', async (req, res) => {
1745
+ const uptime = process.uptime();
1746
+ const memUsage = process.memoryUsage();
1747
+ const socketExists = fs.existsSync(socketPath);
1748
+ // Check relay client connectivity (check if default Dashboard client is connected)
1749
+ const defaultClient = relayClients.get('Dashboard');
1750
+ const relayConnected = defaultClient?.state === 'READY';
1751
+ // If socket doesn't exist, daemon may not be running properly
1752
+ if (!socketExists) {
1753
+ return res.status(503).json({
1754
+ status: 'unhealthy',
1755
+ reason: 'Relay socket not found',
1756
+ uptime,
1757
+ memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
1758
+ });
1759
+ }
1760
+ res.json({
1761
+ status: 'healthy',
1762
+ uptime,
1763
+ memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
1764
+ relayConnected,
1765
+ websocketClients: wss.clients.size,
1766
+ });
1767
+ });
1768
+ /**
1769
+ * GET /api/health - Alternative health endpoint (same as /health)
1770
+ */
1771
+ app.get('/api/health', async (req, res) => {
1772
+ const uptime = process.uptime();
1773
+ const memUsage = process.memoryUsage();
1774
+ const socketExists = fs.existsSync(socketPath);
1775
+ const defaultClient = relayClients.get('Dashboard');
1776
+ const relayConnected = defaultClient?.state === 'READY';
1777
+ if (!socketExists) {
1778
+ return res.status(503).json({
1779
+ status: 'unhealthy',
1780
+ reason: 'Relay socket not found',
1781
+ uptime,
1782
+ memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
1783
+ });
1784
+ }
1785
+ res.json({
1786
+ status: 'healthy',
1787
+ uptime,
1788
+ memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
1789
+ relayConnected,
1790
+ websocketClients: wss.clients.size,
1791
+ });
1792
+ });
1793
+ // ===== CLI Auth API (for workspace-based provider authentication) =====
1794
+ /**
1795
+ * POST /auth/cli/:provider/start - Start CLI auth flow
1796
+ * Body: { useDeviceFlow?: boolean }
1797
+ */
1798
+ app.post('/auth/cli/:provider/start', async (req, res) => {
1799
+ const { provider } = req.params;
1800
+ const { useDeviceFlow } = req.body || {};
1801
+ try {
1802
+ const session = await startCLIAuth(provider, { useDeviceFlow });
1803
+ res.json({
1804
+ sessionId: session.id,
1805
+ status: session.status,
1806
+ authUrl: session.authUrl,
1807
+ });
1808
+ }
1809
+ catch (err) {
1810
+ res.status(400).json({
1811
+ error: err instanceof Error ? err.message : 'Failed to start CLI auth',
1812
+ });
1813
+ }
1814
+ });
1815
+ /**
1816
+ * GET /auth/cli/:provider/status/:sessionId - Get auth session status
1817
+ */
1818
+ app.get('/auth/cli/:provider/status/:sessionId', (req, res) => {
1819
+ const { sessionId } = req.params;
1820
+ const session = getAuthSession(sessionId);
1821
+ if (!session) {
1822
+ return res.status(404).json({ error: 'Session not found' });
1823
+ }
1824
+ res.json({
1825
+ status: session.status,
1826
+ authUrl: session.authUrl,
1827
+ error: session.error,
1828
+ });
1829
+ });
1830
+ /**
1831
+ * GET /auth/cli/:provider/creds/:sessionId - Get credentials from completed auth
1832
+ */
1833
+ app.get('/auth/cli/:provider/creds/:sessionId', (req, res) => {
1834
+ const { sessionId } = req.params;
1835
+ const session = getAuthSession(sessionId);
1836
+ if (!session) {
1837
+ return res.status(404).json({ error: 'Session not found' });
1838
+ }
1839
+ if (session.status !== 'success') {
1840
+ return res.status(400).json({ error: 'Auth not complete', status: session.status });
1841
+ }
1842
+ res.json({
1843
+ token: session.token,
1844
+ refreshToken: session.refreshToken,
1845
+ expiresAt: session.tokenExpiresAt?.toISOString(),
1846
+ });
1847
+ });
1848
+ /**
1849
+ * POST /auth/cli/:provider/cancel/:sessionId - Cancel auth session
1850
+ */
1851
+ app.post('/auth/cli/:provider/cancel/:sessionId', (req, res) => {
1852
+ const { sessionId } = req.params;
1853
+ const cancelled = cancelAuthSession(sessionId);
1854
+ if (!cancelled) {
1855
+ return res.status(404).json({ error: 'Session not found' });
1856
+ }
1857
+ res.json({ success: true });
1858
+ });
1859
+ /**
1860
+ * POST /auth/cli/:provider/code/:sessionId - Submit auth code to PTY
1861
+ * Used when OAuth returns a code that must be pasted into the CLI
1862
+ */
1863
+ app.post('/auth/cli/:provider/code/:sessionId', async (req, res) => {
1864
+ const { provider, sessionId } = req.params;
1865
+ const { code } = req.body;
1866
+ console.log('[cli-auth] Auth code submission received', { provider, sessionId, codeLength: code?.length });
1867
+ if (!code || typeof code !== 'string') {
1868
+ return res.status(400).json({ error: 'Auth code is required' });
1869
+ }
1870
+ try {
1871
+ const result = await submitAuthCode(sessionId, code);
1872
+ console.log('[cli-auth] Auth code submission result', { provider, sessionId, result });
1873
+ if (!result.success) {
1874
+ // Use 400 for all errors since they can be retried
1875
+ return res.status(400).json({
1876
+ error: result.error || 'Session not found or process not running',
1877
+ needsRestart: result.needsRestart ?? true,
1878
+ });
1879
+ }
1880
+ // Wait a few seconds for CLI to process and write credentials
1881
+ // The 1s delay in submitAuthCode + CLI processing time means credentials
1882
+ // should be available within 3-5 seconds
1883
+ let sessionStatus = 'waiting_auth';
1884
+ for (let i = 0; i < 10; i++) {
1885
+ await new Promise(resolve => setTimeout(resolve, 500));
1886
+ const session = getAuthSession(sessionId);
1887
+ if (session?.status === 'success') {
1888
+ sessionStatus = 'success';
1889
+ console.log('[cli-auth] Credentials found after code submission', { provider, sessionId, attempt: i + 1 });
1890
+ break;
1891
+ }
1892
+ if (session?.status === 'error') {
1893
+ sessionStatus = 'error';
1894
+ break;
1895
+ }
1896
+ }
1897
+ res.json({
1898
+ success: true,
1899
+ message: 'Auth code submitted',
1900
+ status: sessionStatus,
1901
+ });
1902
+ }
1903
+ catch (err) {
1904
+ console.error('[cli-auth] Auth code submission error', { provider, sessionId, error: String(err) });
1905
+ return res.status(500).json({
1906
+ error: 'Internal error submitting auth code. Please try again.',
1907
+ needsRestart: true,
1908
+ });
1909
+ }
1910
+ });
1911
+ /**
1912
+ * POST /auth/cli/:provider/complete/:sessionId - Complete auth
1913
+ * For providers like Claude: just polls for credentials
1914
+ * For providers like Codex: accepts authCode (redirect URL) and extracts the code
1915
+ */
1916
+ app.post('/auth/cli/:provider/complete/:sessionId', async (req, res) => {
1917
+ const { sessionId } = req.params;
1918
+ const { authCode } = req.body || {};
1919
+ // If authCode provided, try to extract code and submit it
1920
+ if (authCode && typeof authCode === 'string') {
1921
+ let code = authCode;
1922
+ // If it's a URL, extract the code parameter
1923
+ if (authCode.startsWith('http')) {
1924
+ try {
1925
+ const url = new URL(authCode);
1926
+ const codeParam = url.searchParams.get('code');
1927
+ if (codeParam) {
1928
+ code = codeParam;
1929
+ }
1930
+ }
1931
+ catch {
1932
+ // Not a valid URL, use as-is
1933
+ }
1934
+ }
1935
+ // Submit the code to the CLI process
1936
+ const submitResult = await submitAuthCode(sessionId, code);
1937
+ if (!submitResult.success) {
1938
+ return res.status(400).json({
1939
+ error: submitResult.error,
1940
+ needsRestart: submitResult.needsRestart,
1941
+ });
1942
+ }
1943
+ // Wait a moment for credentials to be written
1944
+ await new Promise(resolve => setTimeout(resolve, 2000));
1945
+ }
1946
+ // Poll for credentials
1947
+ const result = await completeAuthSession(sessionId);
1948
+ if (!result.success) {
1949
+ return res.status(400).json({ error: result.error });
1950
+ }
1951
+ res.json({ success: true, message: 'Authentication complete' });
1952
+ });
1953
+ /**
1954
+ * GET /auth/cli/providers - List supported providers
1955
+ */
1956
+ app.get('/auth/cli/providers', (req, res) => {
1957
+ res.json({ providers: getSupportedProviders() });
1958
+ });
1959
+ // ===== Metrics API =====
1960
+ /**
1961
+ * GET /api/metrics - JSON format metrics for dashboard
1962
+ */
1963
+ app.get('/api/metrics', async (req, res) => {
1964
+ try {
1965
+ // Read agent registry for message counts
1966
+ const agentsPath = path.join(teamDir, 'agents.json');
1967
+ let agentRecords = [];
1968
+ if (fs.existsSync(agentsPath)) {
1969
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
1970
+ agentRecords = (data.agents || []).map((a) => ({
1971
+ name: a.name,
1972
+ messagesSent: a.messagesSent ?? 0,
1973
+ messagesReceived: a.messagesReceived ?? 0,
1974
+ firstSeen: a.firstSeen ?? new Date().toISOString(),
1975
+ lastSeen: a.lastSeen ?? new Date().toISOString(),
1976
+ }));
1977
+ }
1978
+ // Get messages for throughput calculation
1979
+ const team = getTeamData();
1980
+ const messages = team ? await getMessages(team.agents) : [];
1981
+ // Get session data for lifecycle metrics
1982
+ const sessions = storage?.getSessions
1983
+ ? await storage.getSessions({ limit: 100 })
1984
+ : [];
1985
+ const metrics = computeSystemMetrics(agentRecords, messages, sessions);
1986
+ res.json(metrics);
1987
+ }
1988
+ catch (err) {
1989
+ console.error('Failed to compute metrics', err);
1990
+ res.status(500).json({ error: 'Failed to compute metrics' });
1991
+ }
1992
+ });
1993
+ /**
1994
+ * GET /api/metrics/prometheus - Prometheus exposition format
1995
+ */
1996
+ app.get('/api/metrics/prometheus', async (req, res) => {
1997
+ try {
1998
+ // Read agent registry for message counts
1999
+ const agentsPath = path.join(teamDir, 'agents.json');
2000
+ let agentRecords = [];
2001
+ if (fs.existsSync(agentsPath)) {
2002
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
2003
+ agentRecords = (data.agents || []).map((a) => ({
2004
+ name: a.name,
2005
+ messagesSent: a.messagesSent ?? 0,
2006
+ messagesReceived: a.messagesReceived ?? 0,
2007
+ firstSeen: a.firstSeen ?? new Date().toISOString(),
2008
+ lastSeen: a.lastSeen ?? new Date().toISOString(),
2009
+ }));
2010
+ }
2011
+ // Get messages for throughput calculation
2012
+ const team = getTeamData();
2013
+ const messages = team ? await getMessages(team.agents) : [];
2014
+ // Get session data for lifecycle metrics
2015
+ const sessions = storage?.getSessions
2016
+ ? await storage.getSessions({ limit: 100 })
2017
+ : [];
2018
+ const metrics = computeSystemMetrics(agentRecords, messages, sessions);
2019
+ const prometheusOutput = formatPrometheusMetrics(metrics);
2020
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
2021
+ res.send(prometheusOutput);
2022
+ }
747
2023
  catch (err) {
748
2024
  console.error('Failed to compute Prometheus metrics', err);
749
2025
  res.status(500).send('# Error computing metrics\n');
750
2026
  }
751
2027
  });
2028
+ // ===== Agent Memory Metrics API =====
2029
+ /**
2030
+ * GET /api/metrics/agents - Detailed agent memory and resource metrics
2031
+ */
2032
+ app.get('/api/metrics/agents', async (req, res) => {
2033
+ try {
2034
+ const agents = [];
2035
+ // Get metrics from spawner's active workers
2036
+ if (spawner) {
2037
+ const activeWorkers = spawner.getActiveWorkers();
2038
+ for (const worker of activeWorkers) {
2039
+ // Get memory usage via ps command
2040
+ let rssBytes = 0;
2041
+ let cpuPercent = 0;
2042
+ if (worker.pid) {
2043
+ try {
2044
+ const { execSync } = await import('child_process');
2045
+ const output = execSync(`ps -o rss=,pcpu= -p ${worker.pid}`, {
2046
+ encoding: 'utf8',
2047
+ timeout: 3000,
2048
+ }).trim();
2049
+ const parts = output.split(/\s+/);
2050
+ rssBytes = parseInt(parts[0] || '0', 10) * 1024;
2051
+ cpuPercent = parseFloat(parts[1] || '0');
2052
+ }
2053
+ catch {
2054
+ // Process may have exited
2055
+ }
2056
+ }
2057
+ agents.push({
2058
+ name: worker.name,
2059
+ pid: worker.pid,
2060
+ status: worker.pid ? 'running' : 'unknown',
2061
+ rssBytes,
2062
+ cpuPercent,
2063
+ trend: 'unknown',
2064
+ alertLevel: rssBytes > 1024 * 1024 * 1024 ? 'critical' :
2065
+ rssBytes > 512 * 1024 * 1024 ? 'warning' : 'normal',
2066
+ highWatermark: rssBytes,
2067
+ uptimeMs: worker.spawnedAt ? Date.now() - worker.spawnedAt : 0,
2068
+ startedAt: worker.spawnedAt ? new Date(worker.spawnedAt).toISOString() : undefined,
2069
+ });
2070
+ }
2071
+ }
2072
+ // Also check agents.json for registered agents that may not be spawned
2073
+ const agentsPath = path.join(teamDir, 'agents.json');
2074
+ if (fs.existsSync(agentsPath)) {
2075
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
2076
+ const registeredAgents = data.agents || [];
2077
+ for (const agent of registeredAgents) {
2078
+ if (!agents.find(a => a.name === agent.name)) {
2079
+ // Check if recently active (within 30 seconds)
2080
+ const lastSeen = agent.lastSeen ? new Date(agent.lastSeen).getTime() : 0;
2081
+ const isActive = Date.now() - lastSeen < 30000;
2082
+ if (isActive) {
2083
+ agents.push({
2084
+ name: agent.name,
2085
+ status: 'active',
2086
+ alertLevel: 'normal',
2087
+ });
2088
+ }
2089
+ }
2090
+ }
2091
+ }
2092
+ res.json({
2093
+ agents,
2094
+ system: {
2095
+ totalMemory: os.totalmem(),
2096
+ freeMemory: os.freemem(),
2097
+ heapUsed: process.memoryUsage().heapUsed,
2098
+ },
2099
+ });
2100
+ }
2101
+ catch (err) {
2102
+ console.error('Failed to get agent metrics', err);
2103
+ res.status(500).json({ error: 'Failed to get agent metrics' });
2104
+ }
2105
+ });
2106
+ /**
2107
+ * GET /api/metrics/health - System health and crash insights
2108
+ */
2109
+ app.get('/api/metrics/health', async (req, res) => {
2110
+ try {
2111
+ // Calculate health score based on available data
2112
+ let healthScore = 100;
2113
+ const issues = [];
2114
+ const recommendations = [];
2115
+ const crashes = [];
2116
+ const alerts = [];
2117
+ let agentCount = 0;
2118
+ const totalCrashes24h = 0;
2119
+ let totalAlerts24h = 0;
2120
+ // Get spawned agent count
2121
+ if (spawner) {
2122
+ const workers = spawner.getActiveWorkers();
2123
+ agentCount = workers.length;
2124
+ // Check for high memory usage
2125
+ for (const worker of workers) {
2126
+ if (worker.pid) {
2127
+ try {
2128
+ const { execSync } = await import('child_process');
2129
+ const output = execSync(`ps -o rss= -p ${worker.pid}`, {
2130
+ encoding: 'utf8',
2131
+ timeout: 3000,
2132
+ }).trim();
2133
+ const rssBytes = parseInt(output, 10) * 1024;
2134
+ if (rssBytes > 1.5 * 1024 * 1024 * 1024) {
2135
+ // > 1.5GB
2136
+ healthScore -= 20;
2137
+ issues.push({
2138
+ severity: 'critical',
2139
+ message: `Agent "${worker.name}" is using ${Math.round(rssBytes / 1024 / 1024)}MB of memory`,
2140
+ });
2141
+ totalAlerts24h++;
2142
+ alerts.push({
2143
+ id: `alert-${Date.now()}-${worker.name}`,
2144
+ agentName: worker.name,
2145
+ alertType: 'oom_imminent',
2146
+ message: `Memory usage critical: ${Math.round(rssBytes / 1024 / 1024)}MB`,
2147
+ createdAt: new Date().toISOString(),
2148
+ });
2149
+ }
2150
+ else if (rssBytes > 1024 * 1024 * 1024) {
2151
+ // > 1GB
2152
+ healthScore -= 10;
2153
+ issues.push({
2154
+ severity: 'high',
2155
+ message: `Agent "${worker.name}" memory usage is elevated (${Math.round(rssBytes / 1024 / 1024)}MB)`,
2156
+ });
2157
+ }
2158
+ }
2159
+ catch {
2160
+ // Process may have exited
2161
+ }
2162
+ }
2163
+ }
2164
+ }
2165
+ // Check registered agents
2166
+ const agentsPath = path.join(teamDir, 'agents.json');
2167
+ if (fs.existsSync(agentsPath)) {
2168
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
2169
+ const registeredAgents = data.agents || [];
2170
+ const activeAgents = registeredAgents.filter((a) => {
2171
+ const lastSeen = a.lastSeen ? new Date(a.lastSeen).getTime() : 0;
2172
+ return Date.now() - lastSeen < 30000;
2173
+ });
2174
+ agentCount = Math.max(agentCount, activeAgents.length);
2175
+ }
2176
+ // Generate recommendations based on issues
2177
+ if (issues.some(i => i.severity === 'critical')) {
2178
+ recommendations.push('Consider restarting agents with high memory usage');
2179
+ recommendations.push('Monitor system resources closely');
2180
+ }
2181
+ if (agentCount === 0) {
2182
+ recommendations.push('No active agents detected - start agents to begin monitoring');
2183
+ }
2184
+ // Clamp health score
2185
+ healthScore = Math.max(0, Math.min(100, healthScore));
2186
+ // Generate summary
2187
+ let summary;
2188
+ if (healthScore >= 90) {
2189
+ summary = 'System is healthy. All agents operating normally.';
2190
+ }
2191
+ else if (healthScore >= 70) {
2192
+ summary = 'Some issues detected. Review warnings and recommendations.';
2193
+ }
2194
+ else if (healthScore >= 50) {
2195
+ summary = 'Multiple issues detected. Action recommended.';
2196
+ }
2197
+ else {
2198
+ summary = 'Critical issues detected. Immediate action required.';
2199
+ }
2200
+ res.json({
2201
+ healthScore,
2202
+ summary,
2203
+ issues,
2204
+ recommendations,
2205
+ crashes,
2206
+ alerts,
2207
+ stats: {
2208
+ totalCrashes24h,
2209
+ totalAlerts24h,
2210
+ agentCount,
2211
+ },
2212
+ });
2213
+ }
2214
+ catch (err) {
2215
+ console.error('Failed to compute health metrics', err);
2216
+ res.status(500).json({ error: 'Failed to compute health metrics' });
2217
+ }
2218
+ });
2219
+ // ===== File Search API =====
2220
+ /**
2221
+ * GET /api/files - Search for files in the repository
2222
+ * Query params:
2223
+ * - q: Search query (file path pattern)
2224
+ * - limit: Max number of results (default 15)
2225
+ *
2226
+ * This endpoint searches for files in the project root directory
2227
+ * to support @-file autocomplete in the message composer.
2228
+ */
2229
+ app.get('/api/files', async (req, res) => {
2230
+ const query = req.query.q || '';
2231
+ const limit = Math.min(parseInt(req.query.limit, 10) || 15, 50);
2232
+ // Get project root (parent of dataDir, or use projectRoot if available)
2233
+ const searchRoot = options.projectRoot || path.dirname(dataDir);
2234
+ try {
2235
+ const results = await searchFiles(searchRoot, query, limit);
2236
+ res.json({ files: results, query, searchRoot: path.basename(searchRoot) });
2237
+ }
2238
+ catch (err) {
2239
+ console.error('[api] File search error:', err);
2240
+ res.status(500).json({ error: 'Failed to search files', files: [] });
2241
+ }
2242
+ });
752
2243
  // Bridge API endpoint - returns multi-project data
753
2244
  // This is a placeholder that returns empty data when not in bridge mode
754
2245
  // The actual bridge data comes from MultiProjectClient when running `agent-relay bridge`
@@ -774,10 +2265,300 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
774
2265
  res.status(500).json({ error: 'Failed to load bridge data' });
775
2266
  }
776
2267
  });
2268
+ // ===== Conversation History API =====
2269
+ /**
2270
+ * GET /api/history/sessions - List all sessions with filters
2271
+ * Query params:
2272
+ * - agent: Filter by agent name
2273
+ * - since: Filter sessions started after this timestamp (ms)
2274
+ * - limit: Max number of sessions (default 50)
2275
+ */
2276
+ app.get('/api/history/sessions', async (req, res) => {
2277
+ if (!storage) {
2278
+ return res.status(503).json({ error: 'Storage not configured' });
2279
+ }
2280
+ try {
2281
+ const query = {};
2282
+ if (req.query.agent && typeof req.query.agent === 'string') {
2283
+ query.agentName = req.query.agent;
2284
+ }
2285
+ if (req.query.since) {
2286
+ query.since = parseInt(req.query.since, 10);
2287
+ }
2288
+ query.limit = req.query.limit ? parseInt(req.query.limit, 10) : 50;
2289
+ const sessions = storage.getSessions
2290
+ ? await storage.getSessions(query)
2291
+ : [];
2292
+ const result = sessions.map(s => ({
2293
+ id: s.id,
2294
+ agentName: s.agentName,
2295
+ cli: s.cli,
2296
+ startedAt: new Date(s.startedAt).toISOString(),
2297
+ endedAt: s.endedAt ? new Date(s.endedAt).toISOString() : undefined,
2298
+ duration: formatDuration(s.startedAt, s.endedAt),
2299
+ messageCount: s.messageCount,
2300
+ summary: s.summary,
2301
+ isActive: !s.endedAt,
2302
+ closedBy: s.closedBy,
2303
+ }));
2304
+ res.json({ sessions: result });
2305
+ }
2306
+ catch (err) {
2307
+ console.error('Failed to fetch sessions', err);
2308
+ res.status(500).json({ error: 'Failed to fetch sessions' });
2309
+ }
2310
+ });
2311
+ /**
2312
+ * GET /api/history/messages - Get messages with filters
2313
+ * Query params:
2314
+ * - from: Filter by sender
2315
+ * - to: Filter by recipient
2316
+ * - thread: Filter by thread ID
2317
+ * - since: Filter messages after this timestamp (ms)
2318
+ * - limit: Max number of messages (default 100)
2319
+ * - order: 'asc' or 'desc' (default 'desc')
2320
+ * - search: Search in message body (basic substring match)
2321
+ */
2322
+ app.get('/api/history/messages', async (req, res) => {
2323
+ if (!storage) {
2324
+ return res.status(503).json({ error: 'Storage not configured' });
2325
+ }
2326
+ try {
2327
+ const query = {};
2328
+ if (req.query.from && typeof req.query.from === 'string') {
2329
+ query.from = req.query.from;
2330
+ }
2331
+ if (req.query.to && typeof req.query.to === 'string') {
2332
+ query.to = req.query.to;
2333
+ }
2334
+ if (req.query.thread && typeof req.query.thread === 'string') {
2335
+ query.thread = req.query.thread;
2336
+ }
2337
+ if (req.query.since) {
2338
+ query.sinceTs = parseInt(req.query.since, 10);
2339
+ }
2340
+ query.limit = req.query.limit ? parseInt(req.query.limit, 10) : 100;
2341
+ query.order = req.query.order || 'desc';
2342
+ let messages = await storage.getMessages(query);
2343
+ // Filter out messages from/to internal system agents (e.g., __spawner__)
2344
+ messages = messages.filter(m => !isInternalAgent(m.from) && !isInternalAgent(m.to));
2345
+ // Client-side search filter (basic substring match)
2346
+ const searchTerm = req.query.search;
2347
+ if (searchTerm && searchTerm.trim()) {
2348
+ const lowerSearch = searchTerm.toLowerCase();
2349
+ messages = messages.filter(m => m.body.toLowerCase().includes(lowerSearch) ||
2350
+ m.from.toLowerCase().includes(lowerSearch) ||
2351
+ m.to.toLowerCase().includes(lowerSearch));
2352
+ }
2353
+ const result = messages.map(m => ({
2354
+ id: m.id,
2355
+ from: m.from,
2356
+ to: m.to,
2357
+ content: m.body,
2358
+ timestamp: new Date(m.ts).toISOString(),
2359
+ thread: m.thread,
2360
+ isBroadcast: m.is_broadcast,
2361
+ isUrgent: m.is_urgent,
2362
+ status: m.status,
2363
+ }));
2364
+ res.json({ messages: result });
2365
+ }
2366
+ catch (err) {
2367
+ console.error('Failed to fetch messages', err);
2368
+ res.status(500).json({ error: 'Failed to fetch messages' });
2369
+ }
2370
+ });
2371
+ /**
2372
+ * GET /api/history/conversations - Get unique conversations (agent pairs)
2373
+ * Returns list of agent pairs that have exchanged messages
2374
+ */
2375
+ app.get('/api/history/conversations', async (req, res) => {
2376
+ if (!storage) {
2377
+ return res.status(503).json({ error: 'Storage not configured' });
2378
+ }
2379
+ try {
2380
+ // Get all messages to build conversation list
2381
+ const messages = await storage.getMessages({ limit: 1000, order: 'desc' });
2382
+ // Build unique conversation pairs
2383
+ const conversationMap = new Map();
2384
+ for (const msg of messages) {
2385
+ // Skip broadcasts for conversation pairing
2386
+ if (msg.to === '*' || msg.is_broadcast)
2387
+ continue;
2388
+ // Skip messages from/to internal system agents (e.g., __spawner__)
2389
+ if (isInternalAgent(msg.from) || isInternalAgent(msg.to))
2390
+ continue;
2391
+ // Create normalized key (sorted participants)
2392
+ const participants = [msg.from, msg.to].sort();
2393
+ const key = participants.join(':');
2394
+ const existing = conversationMap.get(key);
2395
+ if (existing) {
2396
+ existing.messageCount++;
2397
+ }
2398
+ else {
2399
+ conversationMap.set(key, {
2400
+ participants,
2401
+ lastMessage: msg.body.substring(0, 100),
2402
+ lastTimestamp: new Date(msg.ts).toISOString(),
2403
+ messageCount: 1,
2404
+ });
2405
+ }
2406
+ }
2407
+ // Convert to array sorted by last timestamp
2408
+ const conversations = Array.from(conversationMap.values())
2409
+ .sort((a, b) => new Date(b.lastTimestamp).getTime() - new Date(a.lastTimestamp).getTime());
2410
+ res.json({ conversations });
2411
+ }
2412
+ catch (err) {
2413
+ console.error('Failed to fetch conversations', err);
2414
+ res.status(500).json({ error: 'Failed to fetch conversations' });
2415
+ }
2416
+ });
2417
+ /**
2418
+ * GET /api/history/message/:id - Get a single message by ID
2419
+ */
2420
+ app.get('/api/history/message/:id', async (req, res) => {
2421
+ if (!storage) {
2422
+ return res.status(503).json({ error: 'Storage not configured' });
2423
+ }
2424
+ try {
2425
+ const { id } = req.params;
2426
+ const message = storage.getMessageById
2427
+ ? await storage.getMessageById(id)
2428
+ : null;
2429
+ if (!message) {
2430
+ return res.status(404).json({ error: 'Message not found' });
2431
+ }
2432
+ res.json({
2433
+ id: message.id,
2434
+ from: message.from,
2435
+ to: message.to,
2436
+ content: message.body,
2437
+ timestamp: new Date(message.ts).toISOString(),
2438
+ thread: message.thread,
2439
+ isBroadcast: message.is_broadcast,
2440
+ isUrgent: message.is_urgent,
2441
+ status: message.status,
2442
+ data: message.data,
2443
+ });
2444
+ }
2445
+ catch (err) {
2446
+ console.error('Failed to fetch message', err);
2447
+ res.status(500).json({ error: 'Failed to fetch message' });
2448
+ }
2449
+ });
2450
+ /**
2451
+ * GET /api/history/stats - Get storage statistics
2452
+ */
2453
+ app.get('/api/history/stats', async (req, res) => {
2454
+ if (!storage) {
2455
+ return res.status(503).json({ error: 'Storage not configured' });
2456
+ }
2457
+ try {
2458
+ // Get stats from SQLite adapter if available
2459
+ if (storage instanceof SqliteStorageAdapter) {
2460
+ const stats = await storage.getStats();
2461
+ const sessions = await storage.getSessions({ limit: 1000 });
2462
+ // Calculate additional stats
2463
+ const activeSessions = sessions.filter(s => !s.endedAt).length;
2464
+ const uniqueAgents = new Set(sessions.map(s => s.agentName)).size;
2465
+ res.json({
2466
+ messageCount: stats.messageCount,
2467
+ sessionCount: stats.sessionCount,
2468
+ activeSessions,
2469
+ uniqueAgents,
2470
+ oldestMessageDate: stats.oldestMessageTs
2471
+ ? new Date(stats.oldestMessageTs).toISOString()
2472
+ : null,
2473
+ });
2474
+ }
2475
+ else {
2476
+ // Basic stats for other adapters
2477
+ const messages = await storage.getMessages({ limit: 1 });
2478
+ res.json({
2479
+ messageCount: messages.length > 0 ? 'unknown' : 0,
2480
+ sessionCount: 'unknown',
2481
+ activeSessions: 'unknown',
2482
+ uniqueAgents: 'unknown',
2483
+ });
2484
+ }
2485
+ }
2486
+ catch (err) {
2487
+ console.error('Failed to fetch stats', err);
2488
+ res.status(500).json({ error: 'Failed to fetch stats' });
2489
+ }
2490
+ });
2491
+ // ===== Agent Logs API =====
2492
+ /**
2493
+ * GET /api/logs/:name - Get historical logs for a spawned agent
2494
+ * Query params:
2495
+ * - limit: Max lines to return (default 500)
2496
+ * - raw: If 'true', return raw output instead of cleaned lines
2497
+ */
2498
+ app.get('/api/logs/:name', (req, res) => {
2499
+ if (!spawner) {
2500
+ return res.status(503).json({ error: 'Spawner not enabled' });
2501
+ }
2502
+ const { name } = req.params;
2503
+ const limit = req.query.limit ? parseInt(req.query.limit, 10) : 500;
2504
+ const raw = req.query.raw === 'true';
2505
+ // Check if worker exists
2506
+ if (!spawner.hasWorker(name)) {
2507
+ return res.status(404).json({ error: `Agent ${name} not found` });
2508
+ }
2509
+ try {
2510
+ if (raw) {
2511
+ const output = spawner.getWorkerRawOutput(name);
2512
+ res.json({
2513
+ name,
2514
+ raw: true,
2515
+ output: output || '',
2516
+ timestamp: new Date().toISOString(),
2517
+ });
2518
+ }
2519
+ else {
2520
+ const lines = spawner.getWorkerOutput(name, limit);
2521
+ res.json({
2522
+ name,
2523
+ raw: false,
2524
+ lines: lines || [],
2525
+ lineCount: lines?.length || 0,
2526
+ timestamp: new Date().toISOString(),
2527
+ });
2528
+ }
2529
+ }
2530
+ catch (err) {
2531
+ console.error(`Failed to get logs for ${name}:`, err);
2532
+ res.status(500).json({ error: 'Failed to get logs' });
2533
+ }
2534
+ });
2535
+ /**
2536
+ * GET /api/logs - List all agents with available logs
2537
+ */
2538
+ app.get('/api/logs', (req, res) => {
2539
+ if (!spawner) {
2540
+ return res.status(503).json({ error: 'Spawner not enabled' });
2541
+ }
2542
+ try {
2543
+ const workers = spawner.getActiveWorkers();
2544
+ const agents = workers.map(w => ({
2545
+ name: w.name,
2546
+ cli: w.cli,
2547
+ pid: w.pid,
2548
+ spawnedAt: new Date(w.spawnedAt).toISOString(),
2549
+ hasLogs: true,
2550
+ }));
2551
+ res.json({ agents });
2552
+ }
2553
+ catch (err) {
2554
+ console.error('Failed to list agents with logs:', err);
2555
+ res.status(500).json({ error: 'Failed to list agents' });
2556
+ }
2557
+ });
777
2558
  // ===== Agent Spawn API =====
778
2559
  /**
779
2560
  * POST /api/spawn - Spawn a new agent
780
- * Body: { name: string, cli?: string, task?: string, team?: string }
2561
+ * Body: { name: string, cli?: string, task?: string, team?: string, shadowMode?, shadowAgent?, shadowOf?, shadowTriggers?, shadowSpeakOn? }
781
2562
  */
782
2563
  app.post('/api/spawn', async (req, res) => {
783
2564
  if (!spawner) {
@@ -786,7 +2567,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
786
2567
  error: 'Spawner not enabled. Start dashboard with enableSpawner: true',
787
2568
  });
788
2569
  }
789
- const { name, cli = 'claude', task = '', team } = req.body;
2570
+ const { name, cli = 'claude', task = '', team, shadowMode, shadowAgent, shadowOf, shadowTriggers, shadowSpeakOn, } = req.body;
790
2571
  if (!name || typeof name !== 'string') {
791
2572
  return res.status(400).json({
792
2573
  success: false,
@@ -794,24 +2575,118 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
794
2575
  });
795
2576
  }
796
2577
  try {
797
- const request = {
798
- name,
2578
+ const request = {
2579
+ name,
2580
+ cli,
2581
+ task,
2582
+ team: team || undefined, // Optional team name
2583
+ shadowMode,
2584
+ shadowAgent,
2585
+ shadowOf,
2586
+ shadowTriggers,
2587
+ shadowSpeakOn,
2588
+ };
2589
+ const result = await spawner.spawn(request);
2590
+ if (result.success) {
2591
+ // Broadcast update to WebSocket clients
2592
+ broadcastData().catch(() => { });
2593
+ }
2594
+ res.json(result);
2595
+ }
2596
+ catch (err) {
2597
+ console.error('[api] Spawn error:', err);
2598
+ res.status(500).json({
2599
+ success: false,
2600
+ name,
2601
+ error: err.message,
2602
+ });
2603
+ }
2604
+ });
2605
+ /**
2606
+ * POST /api/spawn/architect - Spawn an Architect agent for bridge mode
2607
+ * Body: { cli?: string }
2608
+ */
2609
+ app.post('/api/spawn/architect', async (req, res) => {
2610
+ if (!spawner) {
2611
+ return res.status(503).json({
2612
+ success: false,
2613
+ error: 'Spawner not enabled. Start dashboard with enableSpawner: true',
2614
+ });
2615
+ }
2616
+ const { cli = 'claude' } = req.body;
2617
+ // Check if Architect already exists
2618
+ const activeWorkers = spawner.getActiveWorkers();
2619
+ if (activeWorkers.some(w => w.name.toLowerCase() === 'architect')) {
2620
+ return res.status(409).json({
2621
+ success: false,
2622
+ error: 'Architect agent already running',
2623
+ });
2624
+ }
2625
+ // Get bridge state for project context
2626
+ const bridgeStatePath = path.join(dataDir, 'bridge-state.json');
2627
+ let projectContext = 'No bridge projects connected.';
2628
+ if (fs.existsSync(bridgeStatePath)) {
2629
+ try {
2630
+ const bridgeState = JSON.parse(fs.readFileSync(bridgeStatePath, 'utf-8'));
2631
+ if (bridgeState.projects && bridgeState.projects.length > 0) {
2632
+ projectContext = bridgeState.projects
2633
+ .map((p) => `- ${p.id}: ${p.path} (Lead: ${p.lead?.name || 'none'})`)
2634
+ .join('\n');
2635
+ }
2636
+ }
2637
+ catch (e) {
2638
+ console.error('[api] Failed to read bridge state:', e);
2639
+ }
2640
+ }
2641
+ // Build the architect prompt
2642
+ const architectPrompt = `You are the Architect, a cross-project coordinator overseeing multiple codebases.
2643
+
2644
+ ## Connected Projects
2645
+ ${projectContext}
2646
+
2647
+ ## Your Role
2648
+ - Coordinate high-level work across all projects
2649
+ - Assign tasks to project leads
2650
+ - Ensure consistency and resolve cross-project dependencies
2651
+ - Review overall architecture decisions
2652
+
2653
+ ## Cross-Project Messaging
2654
+
2655
+ Use this syntax to message agents in specific projects:
2656
+
2657
+ \`\`\`
2658
+ ->relay:project-id:AgentName <<<
2659
+ Your message to this agent>>>
2660
+
2661
+ ->relay:project-id:* <<<
2662
+ Broadcast to all agents in a project>>>
2663
+
2664
+ ->relay:*:* <<<
2665
+ Broadcast to ALL agents in ALL projects>>>
2666
+ \`\`\`
2667
+
2668
+ ## Getting Started
2669
+ 1. Check in with each project lead to understand current status
2670
+ 2. Identify cross-project dependencies
2671
+ 3. Coordinate work across teams
2672
+
2673
+ Start by greeting the project leads and asking for status updates.`;
2674
+ try {
2675
+ const result = await spawner.spawn({
2676
+ name: 'Architect',
799
2677
  cli,
800
- task,
801
- team: team || undefined, // Optional team name
802
- };
803
- const result = await spawner.spawn(request);
2678
+ task: architectPrompt,
2679
+ });
804
2680
  if (result.success) {
805
- // Broadcast update to WebSocket clients
806
2681
  broadcastData().catch(() => { });
807
2682
  }
808
2683
  res.json(result);
809
2684
  }
810
2685
  catch (err) {
811
- console.error('[api] Spawn error:', err);
2686
+ console.error('[api] Architect spawn error:', err);
812
2687
  res.status(500).json({
813
2688
  success: false,
814
- name,
2689
+ name: 'Architect',
815
2690
  error: err.message,
816
2691
  });
817
2692
  }
@@ -864,6 +2739,654 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
864
2739
  });
865
2740
  }
866
2741
  });
2742
+ /**
2743
+ * GET /api/trajectory - Get current trajectory status
2744
+ */
2745
+ app.get('/api/trajectory', async (_req, res) => {
2746
+ try {
2747
+ const status = await getTrajectoryStatus();
2748
+ res.json({
2749
+ success: true,
2750
+ ...status,
2751
+ });
2752
+ }
2753
+ catch (err) {
2754
+ console.error('[api] Trajectory status error:', err);
2755
+ res.status(500).json({
2756
+ success: false,
2757
+ error: err.message,
2758
+ });
2759
+ }
2760
+ });
2761
+ /**
2762
+ * GET /api/trajectory/steps - List trajectory steps
2763
+ */
2764
+ app.get('/api/trajectory/steps', async (req, res) => {
2765
+ try {
2766
+ const trajectoryId = req.query.trajectoryId;
2767
+ const result = await listTrajectorySteps(trajectoryId);
2768
+ if (result.success) {
2769
+ res.json({
2770
+ success: true,
2771
+ steps: result.steps,
2772
+ });
2773
+ }
2774
+ else {
2775
+ res.status(500).json({
2776
+ success: false,
2777
+ steps: [],
2778
+ error: result.error,
2779
+ });
2780
+ }
2781
+ }
2782
+ catch (err) {
2783
+ console.error('[api] Trajectory steps error:', err);
2784
+ res.status(500).json({
2785
+ success: false,
2786
+ steps: [],
2787
+ error: err.message,
2788
+ });
2789
+ }
2790
+ });
2791
+ /**
2792
+ * GET /api/trajectory/history - List all trajectories (completed and active)
2793
+ */
2794
+ app.get('/api/trajectory/history', async (_req, res) => {
2795
+ try {
2796
+ const result = await getTrajectoryHistory();
2797
+ if (result.success) {
2798
+ res.json({
2799
+ success: true,
2800
+ trajectories: result.trajectories,
2801
+ });
2802
+ }
2803
+ else {
2804
+ res.status(500).json({
2805
+ success: false,
2806
+ trajectories: [],
2807
+ error: result.error,
2808
+ });
2809
+ }
2810
+ }
2811
+ catch (err) {
2812
+ console.error('[api] Trajectory history error:', err);
2813
+ res.status(500).json({
2814
+ success: false,
2815
+ trajectories: [],
2816
+ error: err.message,
2817
+ });
2818
+ }
2819
+ });
2820
+ // ===== Settings API =====
2821
+ /**
2822
+ * GET /api/settings - Get all workspace settings with documentation
2823
+ */
2824
+ app.get('/api/settings', async (_req, res) => {
2825
+ try {
2826
+ const { readRelayConfig, shouldStoreInRepo, getTrajectoriesStorageDescription } = await import('../trajectory/config.js');
2827
+ const config = readRelayConfig();
2828
+ res.json({
2829
+ success: true,
2830
+ settings: {
2831
+ trajectories: {
2832
+ storeInRepo: shouldStoreInRepo(),
2833
+ storageLocation: getTrajectoriesStorageDescription(),
2834
+ description: 'Trajectories record the journey of agent work using the PDERO paradigm (Plan, Design, Execute, Review, Observe). They capture decisions, phase transitions, and retrospectives.',
2835
+ benefits: [
2836
+ 'Track why decisions were made, not just what was built',
2837
+ 'Enable session recovery when agents crash or context is lost',
2838
+ 'Provide learning data for future agents working on similar tasks',
2839
+ 'Create an audit trail of agent work for review',
2840
+ ],
2841
+ learnMore: 'https://pdero.com',
2842
+ optInReason: 'Enable "Store in repo" to version-control your trajectories alongside your code. This is useful for teams who want to review agent decision-making processes.',
2843
+ },
2844
+ },
2845
+ config,
2846
+ });
2847
+ }
2848
+ catch (err) {
2849
+ console.error('[api] Settings error:', err);
2850
+ res.status(500).json({
2851
+ success: false,
2852
+ error: err.message,
2853
+ });
2854
+ }
2855
+ });
2856
+ /**
2857
+ * GET /api/settings/trajectory - Get trajectory storage settings
2858
+ */
2859
+ app.get('/api/settings/trajectory', async (_req, res) => {
2860
+ try {
2861
+ const { readRelayConfig, shouldStoreInRepo, getTrajectoriesStorageDescription } = await import('../trajectory/config.js');
2862
+ const config = readRelayConfig();
2863
+ res.json({
2864
+ success: true,
2865
+ settings: {
2866
+ storeInRepo: shouldStoreInRepo(),
2867
+ storageLocation: getTrajectoriesStorageDescription(),
2868
+ },
2869
+ config: config.trajectories || {},
2870
+ // Documentation for the UI
2871
+ documentation: {
2872
+ title: 'Trajectory Storage',
2873
+ description: 'Trajectories record the journey of agent work using the PDERO paradigm (Plan, Design, Execute, Review, Observe).',
2874
+ whatIsIt: 'A trajectory captures not just what an agent built, but WHY it made specific decisions. This includes phase transitions, key decisions with reasoning, and retrospective summaries.',
2875
+ benefits: [
2876
+ 'Understand agent decision-making for code review',
2877
+ 'Enable session recovery if agents crash',
2878
+ 'Train future agents on your codebase patterns',
2879
+ 'Create audit trails of AI work',
2880
+ ],
2881
+ storeInRepoExplanation: 'When enabled, trajectories are stored in .trajectories/ in your repo and can be committed to source control. When disabled (default), they are stored in your user directory (~/.config/agent-relay/trajectories/).',
2882
+ learnMore: 'https://pdero.com',
2883
+ },
2884
+ });
2885
+ }
2886
+ catch (err) {
2887
+ console.error('[api] Settings trajectory error:', err);
2888
+ res.status(500).json({
2889
+ success: false,
2890
+ error: err.message,
2891
+ });
2892
+ }
2893
+ });
2894
+ /**
2895
+ * PUT /api/settings/trajectory - Update trajectory storage settings
2896
+ *
2897
+ * Body: { storeInRepo: boolean }
2898
+ *
2899
+ * This writes to .relay/config.json in the project root
2900
+ */
2901
+ app.put('/api/settings/trajectory', async (req, res) => {
2902
+ try {
2903
+ const { storeInRepo } = req.body;
2904
+ if (typeof storeInRepo !== 'boolean') {
2905
+ return res.status(400).json({
2906
+ success: false,
2907
+ error: 'storeInRepo must be a boolean',
2908
+ });
2909
+ }
2910
+ const { getRelayConfigPath, readRelayConfig } = await import('../trajectory/config.js');
2911
+ const { getProjectPaths } = await import('../utils/project-namespace.js');
2912
+ const { projectRoot: _projectRoot } = getProjectPaths();
2913
+ // Read existing config
2914
+ const config = readRelayConfig();
2915
+ // Update trajectory settings
2916
+ config.trajectories = {
2917
+ ...config.trajectories,
2918
+ storeInRepo,
2919
+ };
2920
+ // Ensure .relay directory exists
2921
+ const configPath = getRelayConfigPath();
2922
+ const configDir = path.dirname(configPath);
2923
+ if (!fs.existsSync(configDir)) {
2924
+ fs.mkdirSync(configDir, { recursive: true });
2925
+ }
2926
+ // Write updated config
2927
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
2928
+ res.json({
2929
+ success: true,
2930
+ settings: {
2931
+ storeInRepo,
2932
+ storageLocation: storeInRepo ? 'repo (.trajectories/)' : 'user (~/.config/agent-relay/trajectories/)',
2933
+ },
2934
+ });
2935
+ }
2936
+ catch (err) {
2937
+ console.error('[api] Settings trajectory update error:', err);
2938
+ res.status(500).json({
2939
+ success: false,
2940
+ error: err.message,
2941
+ });
2942
+ }
2943
+ });
2944
+ const decisions = new Map();
2945
+ /**
2946
+ * GET /api/decisions - List all pending decisions
2947
+ */
2948
+ app.get('/api/decisions', (_req, res) => {
2949
+ const allDecisions = Array.from(decisions.values())
2950
+ .sort((a, b) => {
2951
+ const urgencyOrder = { critical: 0, high: 1, medium: 2, low: 3 };
2952
+ return urgencyOrder[a.urgency] - urgencyOrder[b.urgency];
2953
+ });
2954
+ res.json({ success: true, decisions: allDecisions });
2955
+ });
2956
+ /**
2957
+ * POST /api/decisions - Create a new decision request
2958
+ * Body: { agentName, title, description, options?, urgency, category, expiresAt?, context? }
2959
+ */
2960
+ app.post('/api/decisions', (req, res) => {
2961
+ const { agentName, title, description, options, urgency, category, expiresAt, context } = req.body;
2962
+ if (!agentName || !title || !urgency || !category) {
2963
+ return res.status(400).json({
2964
+ success: false,
2965
+ error: 'Missing required fields: agentName, title, urgency, category',
2966
+ });
2967
+ }
2968
+ const decision = {
2969
+ id: `decision-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2970
+ agentName,
2971
+ title,
2972
+ description: description || '',
2973
+ options,
2974
+ urgency,
2975
+ category,
2976
+ createdAt: new Date().toISOString(),
2977
+ expiresAt,
2978
+ context,
2979
+ };
2980
+ decisions.set(decision.id, decision);
2981
+ // Broadcast to WebSocket clients
2982
+ broadcastData().catch(() => { });
2983
+ res.json({ success: true, decision });
2984
+ });
2985
+ /**
2986
+ * POST /api/decisions/:id/approve - Approve/resolve a decision
2987
+ * Body: { optionId?: string, response?: string }
2988
+ */
2989
+ app.post('/api/decisions/:id/approve', async (req, res) => {
2990
+ const { id } = req.params;
2991
+ const { optionId, response } = req.body;
2992
+ const decision = decisions.get(id);
2993
+ if (!decision) {
2994
+ return res.status(404).json({ success: false, error: 'Decision not found' });
2995
+ }
2996
+ // Send response to the agent via relay
2997
+ const agentName = decision.agentName;
2998
+ let responseMessage = `DECISION APPROVED: ${decision.title}`;
2999
+ if (optionId && decision.options) {
3000
+ const option = decision.options.find(o => o.id === optionId);
3001
+ if (option) {
3002
+ responseMessage += `\nSelected: ${option.label}`;
3003
+ }
3004
+ }
3005
+ if (response) {
3006
+ responseMessage += `\nResponse: ${response}`;
3007
+ }
3008
+ // Try to send message to agent
3009
+ try {
3010
+ const client = await getRelayClient('Dashboard');
3011
+ if (client) {
3012
+ await client.sendMessage(agentName, responseMessage, 'message');
3013
+ }
3014
+ }
3015
+ catch (err) {
3016
+ console.warn('[api] Could not send decision response to agent:', err);
3017
+ }
3018
+ decisions.delete(id);
3019
+ broadcastData().catch(() => { });
3020
+ res.json({ success: true, message: 'Decision approved' });
3021
+ });
3022
+ /**
3023
+ * POST /api/decisions/:id/reject - Reject a decision
3024
+ * Body: { reason?: string }
3025
+ */
3026
+ app.post('/api/decisions/:id/reject', async (req, res) => {
3027
+ const { id } = req.params;
3028
+ const { reason } = req.body;
3029
+ const decision = decisions.get(id);
3030
+ if (!decision) {
3031
+ return res.status(404).json({ success: false, error: 'Decision not found' });
3032
+ }
3033
+ // Send rejection to the agent
3034
+ const agentName = decision.agentName;
3035
+ let responseMessage = `DECISION REJECTED: ${decision.title}`;
3036
+ if (reason) {
3037
+ responseMessage += `\nReason: ${reason}`;
3038
+ }
3039
+ try {
3040
+ const client = await getRelayClient('Dashboard');
3041
+ if (client) {
3042
+ await client.sendMessage(agentName, responseMessage, 'message');
3043
+ }
3044
+ }
3045
+ catch (err) {
3046
+ console.warn('[api] Could not send decision rejection to agent:', err);
3047
+ }
3048
+ decisions.delete(id);
3049
+ broadcastData().catch(() => { });
3050
+ res.json({ success: true, message: 'Decision rejected' });
3051
+ });
3052
+ /**
3053
+ * DELETE /api/decisions/:id - Delete/dismiss a decision
3054
+ */
3055
+ app.delete('/api/decisions/:id', (_req, res) => {
3056
+ const { id } = _req.params;
3057
+ if (!decisions.has(id)) {
3058
+ return res.status(404).json({ success: false, error: 'Decision not found' });
3059
+ }
3060
+ decisions.delete(id);
3061
+ broadcastData().catch(() => { });
3062
+ res.json({ success: true, message: 'Decision dismissed' });
3063
+ });
3064
+ /**
3065
+ * GET /api/fleet/servers - Get fleet server overview
3066
+ * Returns local daemon info + any connected bridge servers
3067
+ * Note: When bridge is active, local agents are already included in bridge project agents,
3068
+ * so we don't add a separate "Local Daemon" entry to avoid double-counting.
3069
+ */
3070
+ app.get('/api/fleet/servers', async (_req, res) => {
3071
+ const servers = [];
3072
+ const localAgents = spawner?.getActiveWorkers() || [];
3073
+ const agentStatuses = await loadAgentStatuses();
3074
+ let hasBridgeProjects = false;
3075
+ // Check for bridge connections first
3076
+ const bridgeStatePath = path.join(dataDir, 'bridge-state.json');
3077
+ if (fs.existsSync(bridgeStatePath)) {
3078
+ try {
3079
+ const bridgeState = JSON.parse(fs.readFileSync(bridgeStatePath, 'utf-8'));
3080
+ if (bridgeState.projects && bridgeState.projects.length > 0) {
3081
+ hasBridgeProjects = true;
3082
+ for (const project of bridgeState.projects) {
3083
+ // Enrich with actual online agents from agents.json (same logic as getBridgeData)
3084
+ // This fixes the bug where stale agents were counted
3085
+ let projectAgents = [];
3086
+ if (project.path) {
3087
+ const projectHash = crypto.createHash('sha256').update(project.path).digest('hex').slice(0, 12);
3088
+ const projectDataDir = path.join(path.dirname(dataDir), projectHash);
3089
+ const projectTeamDir = path.join(projectDataDir, 'team');
3090
+ const agentsPath = path.join(projectTeamDir, 'agents.json');
3091
+ if (fs.existsSync(agentsPath)) {
3092
+ try {
3093
+ const agentsData = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
3094
+ if (agentsData.agents && Array.isArray(agentsData.agents)) {
3095
+ // Filter to only show online agents (seen within 30 seconds)
3096
+ const thirtySecondsAgo = Date.now() - 30 * 1000;
3097
+ projectAgents = agentsData.agents
3098
+ .filter((a) => {
3099
+ if (!a.lastSeen)
3100
+ return false;
3101
+ return new Date(a.lastSeen).getTime() > thirtySecondsAgo;
3102
+ })
3103
+ .map((a) => ({
3104
+ name: a.name,
3105
+ status: 'online',
3106
+ }));
3107
+ }
3108
+ }
3109
+ catch (e) {
3110
+ console.warn(`[api] Failed to read agents for ${project.path}:`, e);
3111
+ }
3112
+ }
3113
+ }
3114
+ servers.push({
3115
+ id: project.id,
3116
+ name: project.name || project.path.split('/').pop() || project.id,
3117
+ status: project.connected ? 'healthy' : 'offline',
3118
+ agents: projectAgents,
3119
+ cpuUsage: 0,
3120
+ memoryUsage: 0,
3121
+ activeConnections: project.connected ? 1 : 0,
3122
+ uptime: 0,
3123
+ lastHeartbeat: project.lastSeen || new Date().toISOString(),
3124
+ });
3125
+ }
3126
+ }
3127
+ }
3128
+ catch (err) {
3129
+ console.warn('[api] Failed to read bridge state:', err);
3130
+ }
3131
+ }
3132
+ // Only add local daemon entry if we don't have bridge projects
3133
+ // (otherwise local agents are already counted in the bridge project)
3134
+ if (!hasBridgeProjects) {
3135
+ servers.push({
3136
+ id: 'local',
3137
+ name: 'Local Daemon',
3138
+ status: 'healthy',
3139
+ agents: localAgents.map(a => ({
3140
+ name: a.name,
3141
+ status: agentStatuses[a.name]?.status || 'unknown',
3142
+ })),
3143
+ cpuUsage: Math.random() * 30, // Mock - would come from actual metrics
3144
+ memoryUsage: Math.random() * 50,
3145
+ activeConnections: wss.clients.size,
3146
+ uptime: process.uptime(),
3147
+ lastHeartbeat: new Date().toISOString(),
3148
+ });
3149
+ }
3150
+ res.json({ success: true, servers });
3151
+ });
3152
+ /**
3153
+ * GET /api/fleet/stats - Get aggregate fleet statistics
3154
+ */
3155
+ app.get('/api/fleet/stats', async (_req, res) => {
3156
+ const localAgents = spawner?.getActiveWorkers() || [];
3157
+ const agentStatuses = await loadAgentStatuses();
3158
+ const totalAgents = localAgents.length;
3159
+ let onlineAgents = 0;
3160
+ let busyAgents = 0;
3161
+ for (const agent of localAgents) {
3162
+ const status = agentStatuses[agent.name]?.status;
3163
+ if (status === 'online')
3164
+ onlineAgents++;
3165
+ if (status === 'busy')
3166
+ busyAgents++;
3167
+ }
3168
+ res.json({
3169
+ success: true,
3170
+ stats: {
3171
+ totalAgents,
3172
+ onlineAgents,
3173
+ busyAgents,
3174
+ pendingDecisions: decisions.size,
3175
+ activeTasks: Array.from(tasks.values()).filter(t => t.status === 'assigned' || t.status === 'in_progress').length,
3176
+ },
3177
+ });
3178
+ });
3179
+ const tasks = new Map();
3180
+ /**
3181
+ * GET /api/tasks - List all tasks
3182
+ */
3183
+ app.get('/api/tasks', (req, res) => {
3184
+ const status = req.query.status;
3185
+ const agentName = req.query.agent;
3186
+ let allTasks = Array.from(tasks.values());
3187
+ if (status) {
3188
+ allTasks = allTasks.filter(t => t.status === status);
3189
+ }
3190
+ if (agentName) {
3191
+ allTasks = allTasks.filter(t => t.agentName === agentName);
3192
+ }
3193
+ // Sort by priority and creation time
3194
+ allTasks.sort((a, b) => {
3195
+ const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
3196
+ const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
3197
+ if (priorityDiff !== 0)
3198
+ return priorityDiff;
3199
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
3200
+ });
3201
+ res.json({ success: true, tasks: allTasks });
3202
+ });
3203
+ /**
3204
+ * POST /api/tasks - Create and assign a task
3205
+ * Body: { agentName, title, description, priority }
3206
+ */
3207
+ app.post('/api/tasks', async (req, res) => {
3208
+ const { agentName, title, description, priority } = req.body;
3209
+ if (!agentName || !title || !priority) {
3210
+ return res.status(400).json({
3211
+ success: false,
3212
+ error: 'Missing required fields: agentName, title, priority',
3213
+ });
3214
+ }
3215
+ const task = {
3216
+ id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3217
+ agentName,
3218
+ title,
3219
+ description: description || '',
3220
+ priority,
3221
+ status: 'assigned',
3222
+ createdAt: new Date().toISOString(),
3223
+ assignedAt: new Date().toISOString(),
3224
+ };
3225
+ tasks.set(task.id, task);
3226
+ // Send task to agent via relay
3227
+ try {
3228
+ const client = await getRelayClient('Dashboard');
3229
+ if (client) {
3230
+ const taskMessage = `TASK ASSIGNED [${priority.toUpperCase()}]: ${title}\n\n${description || 'No additional details.'}`;
3231
+ await client.sendMessage(agentName, taskMessage, 'message');
3232
+ }
3233
+ }
3234
+ catch (err) {
3235
+ console.warn('[api] Could not send task to agent:', err);
3236
+ }
3237
+ broadcastData().catch(() => { });
3238
+ res.json({ success: true, task });
3239
+ });
3240
+ /**
3241
+ * PATCH /api/tasks/:id - Update task status
3242
+ * Body: { status, result? }
3243
+ */
3244
+ app.patch('/api/tasks/:id', (req, res) => {
3245
+ const { id } = req.params;
3246
+ const { status, result } = req.body;
3247
+ const task = tasks.get(id);
3248
+ if (!task) {
3249
+ return res.status(404).json({ success: false, error: 'Task not found' });
3250
+ }
3251
+ if (status) {
3252
+ task.status = status;
3253
+ if (status === 'completed' || status === 'failed') {
3254
+ task.completedAt = new Date().toISOString();
3255
+ }
3256
+ }
3257
+ if (result !== undefined) {
3258
+ task.result = result;
3259
+ }
3260
+ tasks.set(id, task);
3261
+ broadcastData().catch(() => { });
3262
+ res.json({ success: true, task });
3263
+ });
3264
+ /**
3265
+ * DELETE /api/tasks/:id - Cancel/delete a task
3266
+ */
3267
+ app.delete('/api/tasks/:id', async (req, res) => {
3268
+ const { id } = req.params;
3269
+ const task = tasks.get(id);
3270
+ if (!task) {
3271
+ return res.status(404).json({ success: false, error: 'Task not found' });
3272
+ }
3273
+ // Notify agent of cancellation if task is still active
3274
+ if (task.status === 'pending' || task.status === 'assigned' || task.status === 'in_progress') {
3275
+ try {
3276
+ const client = await getRelayClient('Dashboard');
3277
+ if (client) {
3278
+ await client.sendMessage(task.agentName, `TASK CANCELLED: ${task.title}`, 'message');
3279
+ }
3280
+ }
3281
+ catch (err) {
3282
+ console.warn('[api] Could not send task cancellation to agent:', err);
3283
+ }
3284
+ }
3285
+ tasks.delete(id);
3286
+ broadcastData().catch(() => { });
3287
+ res.json({ success: true, message: 'Task cancelled' });
3288
+ });
3289
+ // ===== Beads Integration API =====
3290
+ /**
3291
+ * POST /api/beads - Create a bead (task/issue) via the bd CLI
3292
+ */
3293
+ app.post('/api/beads', async (req, res) => {
3294
+ const { title, assignee, priority, type, description: _description } = req.body;
3295
+ if (!title || typeof title !== 'string') {
3296
+ return res.status(400).json({ success: false, error: 'Title is required' });
3297
+ }
3298
+ // Build bd create command
3299
+ const args = ['create', `--title="${title.replace(/"/g, '\\"')}"`];
3300
+ if (assignee) {
3301
+ args.push(`--assignee=${assignee}`);
3302
+ }
3303
+ if (priority !== undefined && priority !== null) {
3304
+ args.push(`--priority=${priority}`);
3305
+ }
3306
+ if (type && ['task', 'bug', 'feature'].includes(type)) {
3307
+ args.push(`--type=${type}`);
3308
+ }
3309
+ const cmd = `bd ${args.join(' ')}`;
3310
+ console.log('[api/beads] Creating bead:', cmd);
3311
+ // Execute bd create command
3312
+ exec(cmd, { cwd: dataDir }, (error, stdout, stderr) => {
3313
+ if (error) {
3314
+ console.error('[api/beads] bd create failed:', stderr || error.message);
3315
+ return res.status(500).json({
3316
+ success: false,
3317
+ error: stderr || error.message || 'Failed to create bead',
3318
+ });
3319
+ }
3320
+ // Parse bead ID from output (bd create outputs the ID)
3321
+ const output = stdout.trim();
3322
+ // bd create typically outputs: "Created beads-xxx: title"
3323
+ const idMatch = output.match(/Created\s+(beads-\w+)/i) || output.match(/(beads-\w+)/);
3324
+ const beadId = idMatch ? idMatch[1] : `beads-${Date.now()}`;
3325
+ console.log('[api/beads] Created bead:', beadId);
3326
+ res.json({
3327
+ success: true,
3328
+ bead: {
3329
+ id: beadId,
3330
+ title,
3331
+ assignee,
3332
+ priority,
3333
+ type: type || 'task',
3334
+ },
3335
+ });
3336
+ });
3337
+ });
3338
+ /**
3339
+ * POST /api/relay/send - Send a relay message to an agent
3340
+ */
3341
+ app.post('/api/relay/send', async (req, res) => {
3342
+ const { to, content, thread } = req.body;
3343
+ if (!to || typeof to !== 'string') {
3344
+ return res.status(400).json({ success: false, error: 'Recipient (to) is required' });
3345
+ }
3346
+ if (!content || typeof content !== 'string') {
3347
+ return res.status(400).json({ success: false, error: 'Message content is required' });
3348
+ }
3349
+ try {
3350
+ const client = await getRelayClient('Dashboard');
3351
+ if (!client) {
3352
+ return res.status(503).json({
3353
+ success: false,
3354
+ error: 'Relay client not available',
3355
+ });
3356
+ }
3357
+ const messageId = await client.sendMessage(to, content, thread ? 'message' : 'message');
3358
+ console.log('[api/relay/send] Sent message to', to, ':', messageId);
3359
+ res.json({
3360
+ success: true,
3361
+ messageId: messageId || `msg-${Date.now()}`,
3362
+ });
3363
+ }
3364
+ catch (err) {
3365
+ console.error('[api/relay/send] Failed to send message:', err);
3366
+ res.status(500).json({
3367
+ success: false,
3368
+ error: err instanceof Error ? err.message : 'Failed to send message',
3369
+ });
3370
+ }
3371
+ });
3372
+ // Helper to load agent statuses
3373
+ async function loadAgentStatuses() {
3374
+ const agentsFile = path.join(dataDir, 'agents.json');
3375
+ try {
3376
+ if (fs.existsSync(agentsFile)) {
3377
+ const data = JSON.parse(fs.readFileSync(agentsFile, 'utf-8'));
3378
+ const result = {};
3379
+ for (const agent of data.agents || []) {
3380
+ result[agent.name] = { status: agent.status || 'offline' };
3381
+ }
3382
+ return result;
3383
+ }
3384
+ }
3385
+ catch (err) {
3386
+ console.warn('[api] Failed to load agent statuses:', err);
3387
+ }
3388
+ return {};
3389
+ }
867
3390
  // Watch for changes
868
3391
  if (storage) {
869
3392
  setInterval(() => {
@@ -878,7 +3401,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
878
3401
  if (fs.existsSync(dataDir)) {
879
3402
  console.log(`Watching ${dataDir} for changes...`);
880
3403
  fs.watch(dataDir, { recursive: true }, (eventType, filename) => {
881
- if (filename && (filename.endsWith('inbox.md') || filename.endsWith('team.json') || filename.endsWith('agents.json'))) {
3404
+ if (filename && (filename.endsWith('inbox.md') || filename.endsWith('team.json') || filename.endsWith('agents.json') || filename.endsWith('processing-state.json'))) {
882
3405
  // Debounce
883
3406
  if (fsWait)
884
3407
  return;