@aoagents/ao-web 0.2.2

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 (230) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +293 -0
  3. package/.next/app-path-routes-manifest.json +33 -0
  4. package/.next/build-manifest.json +33 -0
  5. package/.next/export-marker.json +6 -0
  6. package/.next/images-manifest.json +58 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +172 -0
  11. package/.next/react-loadable-manifest.json +47 -0
  12. package/.next/required-server-files.json +330 -0
  13. package/.next/routes-manifest.json +195 -0
  14. package/.next/server/app/_not-found/page.js +2 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +8 -0
  19. package/.next/server/app/_not-found.rsc +23 -0
  20. package/.next/server/app/api/backlog/route.js +1 -0
  21. package/.next/server/app/api/backlog/route.js.nft.json +1 -0
  22. package/.next/server/app/api/backlog/route_client-reference-manifest.js +1 -0
  23. package/.next/server/app/api/events/route.js +9 -0
  24. package/.next/server/app/api/events/route.js.nft.json +1 -0
  25. package/.next/server/app/api/events/route_client-reference-manifest.js +1 -0
  26. package/.next/server/app/api/issues/route.js +1 -0
  27. package/.next/server/app/api/issues/route.js.nft.json +1 -0
  28. package/.next/server/app/api/issues/route_client-reference-manifest.js +1 -0
  29. package/.next/server/app/api/observability/route.js +1 -0
  30. package/.next/server/app/api/observability/route.js.nft.json +1 -0
  31. package/.next/server/app/api/observability/route_client-reference-manifest.js +1 -0
  32. package/.next/server/app/api/orchestrators/route.js +1 -0
  33. package/.next/server/app/api/orchestrators/route.js.nft.json +1 -0
  34. package/.next/server/app/api/orchestrators/route_client-reference-manifest.js +1 -0
  35. package/.next/server/app/api/projects/route.js +1 -0
  36. package/.next/server/app/api/projects/route.js.nft.json +1 -0
  37. package/.next/server/app/api/projects/route_client-reference-manifest.js +1 -0
  38. package/.next/server/app/api/prs/[id]/merge/route.js +1 -0
  39. package/.next/server/app/api/prs/[id]/merge/route.js.nft.json +1 -0
  40. package/.next/server/app/api/prs/[id]/merge/route_client-reference-manifest.js +1 -0
  41. package/.next/server/app/api/runtime/terminal/route.js +1 -0
  42. package/.next/server/app/api/runtime/terminal/route.js.nft.json +1 -0
  43. package/.next/server/app/api/runtime/terminal/route_client-reference-manifest.js +1 -0
  44. package/.next/server/app/api/sessions/[id]/kill/route.js +1 -0
  45. package/.next/server/app/api/sessions/[id]/kill/route.js.nft.json +1 -0
  46. package/.next/server/app/api/sessions/[id]/kill/route_client-reference-manifest.js +1 -0
  47. package/.next/server/app/api/sessions/[id]/message/route.js +1 -0
  48. package/.next/server/app/api/sessions/[id]/message/route.js.nft.json +1 -0
  49. package/.next/server/app/api/sessions/[id]/message/route_client-reference-manifest.js +1 -0
  50. package/.next/server/app/api/sessions/[id]/remap/route.js +1 -0
  51. package/.next/server/app/api/sessions/[id]/remap/route.js.nft.json +1 -0
  52. package/.next/server/app/api/sessions/[id]/remap/route_client-reference-manifest.js +1 -0
  53. package/.next/server/app/api/sessions/[id]/restore/route.js +1 -0
  54. package/.next/server/app/api/sessions/[id]/restore/route.js.nft.json +1 -0
  55. package/.next/server/app/api/sessions/[id]/restore/route_client-reference-manifest.js +1 -0
  56. package/.next/server/app/api/sessions/[id]/route.js +1 -0
  57. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -0
  58. package/.next/server/app/api/sessions/[id]/route_client-reference-manifest.js +1 -0
  59. package/.next/server/app/api/sessions/[id]/send/route.js +1 -0
  60. package/.next/server/app/api/sessions/[id]/send/route.js.nft.json +1 -0
  61. package/.next/server/app/api/sessions/[id]/send/route_client-reference-manifest.js +1 -0
  62. package/.next/server/app/api/sessions/route.js +1 -0
  63. package/.next/server/app/api/sessions/route.js.nft.json +1 -0
  64. package/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -0
  65. package/.next/server/app/api/setup-labels/route.js +1 -0
  66. package/.next/server/app/api/setup-labels/route.js.nft.json +1 -0
  67. package/.next/server/app/api/setup-labels/route_client-reference-manifest.js +1 -0
  68. package/.next/server/app/api/spawn/route.js +1 -0
  69. package/.next/server/app/api/spawn/route.js.nft.json +1 -0
  70. package/.next/server/app/api/spawn/route_client-reference-manifest.js +1 -0
  71. package/.next/server/app/api/verify/route.js +1 -0
  72. package/.next/server/app/api/verify/route.js.nft.json +1 -0
  73. package/.next/server/app/api/verify/route_client-reference-manifest.js +1 -0
  74. package/.next/server/app/api/webhooks/[...slug]/route.js +1 -0
  75. package/.next/server/app/api/webhooks/[...slug]/route.js.nft.json +1 -0
  76. package/.next/server/app/api/webhooks/[...slug]/route_client-reference-manifest.js +1 -0
  77. package/.next/server/app/apple-icon/route.js +1 -0
  78. package/.next/server/app/apple-icon/route.js.nft.json +1 -0
  79. package/.next/server/app/apple-icon/route_client-reference-manifest.js +1 -0
  80. package/.next/server/app/apple-icon.body +0 -0
  81. package/.next/server/app/apple-icon.meta +1 -0
  82. package/.next/server/app/dev/terminal-test/page.js +15 -0
  83. package/.next/server/app/dev/terminal-test/page.js.nft.json +1 -0
  84. package/.next/server/app/dev/terminal-test/page_client-reference-manifest.js +1 -0
  85. package/.next/server/app/dev/terminal-test.html +1 -0
  86. package/.next/server/app/dev/terminal-test.meta +7 -0
  87. package/.next/server/app/dev/terminal-test.rsc +27 -0
  88. package/.next/server/app/icon/route.js +1 -0
  89. package/.next/server/app/icon/route.js.nft.json +1 -0
  90. package/.next/server/app/icon/route_client-reference-manifest.js +1 -0
  91. package/.next/server/app/icon-192/route.js +1 -0
  92. package/.next/server/app/icon-192/route.js.nft.json +1 -0
  93. package/.next/server/app/icon-192/route_client-reference-manifest.js +1 -0
  94. package/.next/server/app/icon-512/route.js +1 -0
  95. package/.next/server/app/icon-512/route.js.nft.json +1 -0
  96. package/.next/server/app/icon-512/route_client-reference-manifest.js +1 -0
  97. package/.next/server/app/icon.body +0 -0
  98. package/.next/server/app/icon.meta +1 -0
  99. package/.next/server/app/manifest.webmanifest/route.js +16 -0
  100. package/.next/server/app/manifest.webmanifest/route.js.nft.json +1 -0
  101. package/.next/server/app/manifest.webmanifest/route_client-reference-manifest.js +1 -0
  102. package/.next/server/app/manifest.webmanifest.body +1 -0
  103. package/.next/server/app/manifest.webmanifest.meta +1 -0
  104. package/.next/server/app/orchestrators/page.js +2 -0
  105. package/.next/server/app/orchestrators/page.js.nft.json +1 -0
  106. package/.next/server/app/orchestrators/page_client-reference-manifest.js +1 -0
  107. package/.next/server/app/page.js +2 -0
  108. package/.next/server/app/page.js.nft.json +1 -0
  109. package/.next/server/app/page_client-reference-manifest.js +1 -0
  110. package/.next/server/app/prs/page.js +2 -0
  111. package/.next/server/app/prs/page.js.nft.json +1 -0
  112. package/.next/server/app/prs/page_client-reference-manifest.js +1 -0
  113. package/.next/server/app/sessions/[id]/page.js +12 -0
  114. package/.next/server/app/sessions/[id]/page.js.nft.json +1 -0
  115. package/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -0
  116. package/.next/server/app/test-direct/page.js +2 -0
  117. package/.next/server/app/test-direct/page.js.nft.json +1 -0
  118. package/.next/server/app/test-direct/page_client-reference-manifest.js +1 -0
  119. package/.next/server/app/test-direct.html +1 -0
  120. package/.next/server/app/test-direct.meta +7 -0
  121. package/.next/server/app/test-direct.rsc +27 -0
  122. package/.next/server/app-paths-manifest.json +33 -0
  123. package/.next/server/chunks/27.js +438 -0
  124. package/.next/server/chunks/377.js +1 -0
  125. package/.next/server/chunks/393.js +1 -0
  126. package/.next/server/chunks/627.js +391 -0
  127. package/.next/server/chunks/639.js +6 -0
  128. package/.next/server/chunks/693.js +1 -0
  129. package/.next/server/chunks/705.js +22 -0
  130. package/.next/server/chunks/787.js +29 -0
  131. package/.next/server/chunks/796.js +1 -0
  132. package/.next/server/chunks/934.js +1 -0
  133. package/.next/server/chunks/956.js +9 -0
  134. package/.next/server/functions-config-manifest.json +4 -0
  135. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  136. package/.next/server/middleware-build-manifest.js +1 -0
  137. package/.next/server/middleware-manifest.json +6 -0
  138. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  139. package/.next/server/next-font-manifest.js +1 -0
  140. package/.next/server/next-font-manifest.json +1 -0
  141. package/.next/server/pages/404.html +1 -0
  142. package/.next/server/pages/500.html +1 -0
  143. package/.next/server/pages/_app.js +1 -0
  144. package/.next/server/pages/_app.js.nft.json +1 -0
  145. package/.next/server/pages/_document.js +1 -0
  146. package/.next/server/pages/_document.js.nft.json +1 -0
  147. package/.next/server/pages/_error.js +19 -0
  148. package/.next/server/pages/_error.js.nft.json +1 -0
  149. package/.next/server/pages-manifest.json +6 -0
  150. package/.next/server/server-reference-manifest.js +1 -0
  151. package/.next/server/server-reference-manifest.json +1 -0
  152. package/.next/server/webpack-runtime.js +1 -0
  153. package/.next/static/3WYmn9ZvJ5NqH2dUKVdTc/_buildManifest.js +1 -0
  154. package/.next/static/3WYmn9ZvJ5NqH2dUKVdTc/_ssgManifest.js +1 -0
  155. package/.next/static/chunks/1250-e7cf6b069fbb03ed.js +1 -0
  156. package/.next/static/chunks/2205.498806f73783aa54.js +1 -0
  157. package/.next/static/chunks/3698-9c12c45b8184022c.js +1 -0
  158. package/.next/static/chunks/6381.1541d5695a727108.js +1 -0
  159. package/.next/static/chunks/7411.ecda44797fb514a0.js +1 -0
  160. package/.next/static/chunks/7505-2d2422d31862995f.js +1 -0
  161. package/.next/static/chunks/8597-1385f90ec1cebf47.js +1 -0
  162. package/.next/static/chunks/8762.f3d526855363db16.js +1 -0
  163. package/.next/static/chunks/9393.acf1934a190d793b.js +1 -0
  164. package/.next/static/chunks/a51c26f2-a21e680a5df6764e.js +1 -0
  165. package/.next/static/chunks/app/_not-found/page-2224bc1d3dce1b3e.js +1 -0
  166. package/.next/static/chunks/app/api/backlog/route-2224bc1d3dce1b3e.js +1 -0
  167. package/.next/static/chunks/app/api/events/route-2224bc1d3dce1b3e.js +1 -0
  168. package/.next/static/chunks/app/api/issues/route-2224bc1d3dce1b3e.js +1 -0
  169. package/.next/static/chunks/app/api/observability/route-2224bc1d3dce1b3e.js +1 -0
  170. package/.next/static/chunks/app/api/orchestrators/route-2224bc1d3dce1b3e.js +1 -0
  171. package/.next/static/chunks/app/api/projects/route-2224bc1d3dce1b3e.js +1 -0
  172. package/.next/static/chunks/app/api/prs/[id]/merge/route-2224bc1d3dce1b3e.js +1 -0
  173. package/.next/static/chunks/app/api/runtime/terminal/route-2224bc1d3dce1b3e.js +1 -0
  174. package/.next/static/chunks/app/api/sessions/[id]/kill/route-2224bc1d3dce1b3e.js +1 -0
  175. package/.next/static/chunks/app/api/sessions/[id]/message/route-2224bc1d3dce1b3e.js +1 -0
  176. package/.next/static/chunks/app/api/sessions/[id]/remap/route-2224bc1d3dce1b3e.js +1 -0
  177. package/.next/static/chunks/app/api/sessions/[id]/restore/route-2224bc1d3dce1b3e.js +1 -0
  178. package/.next/static/chunks/app/api/sessions/[id]/route-2224bc1d3dce1b3e.js +1 -0
  179. package/.next/static/chunks/app/api/sessions/[id]/send/route-2224bc1d3dce1b3e.js +1 -0
  180. package/.next/static/chunks/app/api/sessions/route-2224bc1d3dce1b3e.js +1 -0
  181. package/.next/static/chunks/app/api/setup-labels/route-2224bc1d3dce1b3e.js +1 -0
  182. package/.next/static/chunks/app/api/spawn/route-2224bc1d3dce1b3e.js +1 -0
  183. package/.next/static/chunks/app/api/verify/route-2224bc1d3dce1b3e.js +1 -0
  184. package/.next/static/chunks/app/api/webhooks/[...slug]/route-2224bc1d3dce1b3e.js +1 -0
  185. package/.next/static/chunks/app/apple-icon/route-2224bc1d3dce1b3e.js +1 -0
  186. package/.next/static/chunks/app/dev/terminal-test/page-ac0ce5b046fcad82.js +1 -0
  187. package/.next/static/chunks/app/error-4896c9d3b7681a80.js +1 -0
  188. package/.next/static/chunks/app/global-error-7b5c8ae45329c659.js +1 -0
  189. package/.next/static/chunks/app/icon/route-2224bc1d3dce1b3e.js +1 -0
  190. package/.next/static/chunks/app/icon-192/route-2224bc1d3dce1b3e.js +1 -0
  191. package/.next/static/chunks/app/icon-512/route-2224bc1d3dce1b3e.js +1 -0
  192. package/.next/static/chunks/app/layout-023f2083204e4f68.js +1 -0
  193. package/.next/static/chunks/app/loading-2224bc1d3dce1b3e.js +1 -0
  194. package/.next/static/chunks/app/manifest.webmanifest/route-2224bc1d3dce1b3e.js +1 -0
  195. package/.next/static/chunks/app/not-found-3772c2e09c29d80f.js +1 -0
  196. package/.next/static/chunks/app/orchestrators/page-df85236df674fc5a.js +1 -0
  197. package/.next/static/chunks/app/page-e97da633b7ef25f2.js +1 -0
  198. package/.next/static/chunks/app/prs/page-8cc0fce584d238c5.js +1 -0
  199. package/.next/static/chunks/app/sessions/[id]/error-90bc99b777fecabf.js +1 -0
  200. package/.next/static/chunks/app/sessions/[id]/loading-2224bc1d3dce1b3e.js +1 -0
  201. package/.next/static/chunks/app/sessions/[id]/not-found-3772c2e09c29d80f.js +1 -0
  202. package/.next/static/chunks/app/sessions/[id]/page-c9c95a8604786cff.js +1 -0
  203. package/.next/static/chunks/app/test-direct/page-16ceeb9f7664394a.js +1 -0
  204. package/.next/static/chunks/df4ed4d4.6a752eba3933e9a8.js +3 -0
  205. package/.next/static/chunks/framework-f8b8ba0f71d38056.js +1 -0
  206. package/.next/static/chunks/main-2bc85c765bf1fc49.js +1 -0
  207. package/.next/static/chunks/main-app-93fb36c3bd1a6739.js +1 -0
  208. package/.next/static/chunks/pages/_app-a0b975794f15bd68.js +1 -0
  209. package/.next/static/chunks/pages/_error-5f4e3b5eea57917d.js +1 -0
  210. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  211. package/.next/static/chunks/webpack-e12ceebeb7a1cc7e.js +1 -0
  212. package/.next/static/css/46a7e8e962075e54.css +1 -0
  213. package/.next/static/css/6ef2fa08dd043252.css +1 -0
  214. package/.next/static/css/908f93fdd7ffba42.css +1 -0
  215. package/.next/static/media/4cf2300e9c8272f7-s.p.woff2 +0 -0
  216. package/.next/static/media/558ca1a6aa3cb55e-s.p.woff2 +0 -0
  217. package/.next/static/media/64d784ea54a4acde-s.woff2 +0 -0
  218. package/.next/static/media/6d831b18ae5b01dc-s.woff2 +0 -0
  219. package/.next/static/media/8d697b304b401681-s.woff2 +0 -0
  220. package/.next/static/media/ac0e76ddaeeb7981-s.woff2 +0 -0
  221. package/.next/static/media/ba015fad6dcf6784-s.woff2 +0 -0
  222. package/.next/static/media/edc640959b0c7826-s.woff2 +0 -0
  223. package/.next/static/media/ff71da380fbe67dd-s.woff2 +0 -0
  224. package/dist-server/direct-terminal-ws.js +297 -0
  225. package/dist-server/start-all.js +113 -0
  226. package/dist-server/terminal-observability.js +16 -0
  227. package/dist-server/terminal-websocket.js +375 -0
  228. package/dist-server/tmux-utils.js +89 -0
  229. package/next.config.js +33 -0
  230. package/package.json +71 -0
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Terminal server that manages ttyd instances for tmux sessions.
3
+ *
4
+ * Runs alongside Next.js. Spawns a ttyd process per session on demand,
5
+ * each on a unique port. The dashboard embeds ttyd via iframe.
6
+ *
7
+ * ttyd handles all the hard parts: xterm.js, WebSocket, ANSI rendering,
8
+ * cursor positioning, resize, input — battle-tested and correct.
9
+ *
10
+ * TODO: Add authentication middleware to verify:
11
+ * - User is authenticated
12
+ * - User owns the requested session
13
+ * - Rate limiting for terminal access
14
+ */
15
+ import { spawn } from "node:child_process";
16
+ import { createServer, request } from "node:http";
17
+ import { createCorrelationId } from "@aoagents/ao-core";
18
+ import { findTmux, resolveTmuxSession, validateSessionId } from "./tmux-utils.js";
19
+ import { createObserverContext, inferProjectId } from "./terminal-observability.js";
20
+ /** Cached full path to tmux binary */
21
+ const TMUX = findTmux();
22
+ console.log(`[Terminal] Using tmux: ${TMUX}`);
23
+ const instances = new Map();
24
+ const metrics = {
25
+ activeInstances: 0,
26
+ totalSpawns: 0,
27
+ totalErrors: 0,
28
+ totalReused: 0,
29
+ };
30
+ const availablePorts = new Set(); // Pool of recycled ports
31
+ let nextPort = 7800; // Start ttyd instances from port 7800
32
+ const MAX_PORT = 7900; // Prevent unbounded port allocation
33
+ const { config: observabilityConfig, observer } = createObserverContext("terminal-websocket");
34
+ function recordWebsocketMetric(input) {
35
+ if (!observer) {
36
+ return;
37
+ }
38
+ const correlationId = createCorrelationId("ws");
39
+ observer.recordOperation({
40
+ metric: input.metric,
41
+ operation: `terminal.websocket.${input.metric}`,
42
+ outcome: input.outcome,
43
+ correlationId,
44
+ projectId: input.sessionId ? inferProjectId(observabilityConfig, input.sessionId) : undefined,
45
+ sessionId: input.sessionId,
46
+ reason: input.reason,
47
+ data: input.data,
48
+ level: input.outcome === "failure" ? "error" : "info",
49
+ });
50
+ }
51
+ /**
52
+ * Check if ttyd is ready to accept connections by making a test request.
53
+ * Returns a promise that resolves when ttyd is ready or rejects after timeout.
54
+ * Properly cancels pending timeouts and requests to prevent memory leaks.
55
+ */
56
+ function waitForTtyd(port, sessionId, timeoutMs = 3000) {
57
+ const startTime = Date.now();
58
+ let timeoutId = null;
59
+ let pendingReq = null;
60
+ let settled = false;
61
+ return new Promise((resolve, reject) => {
62
+ const cleanup = () => {
63
+ settled = true;
64
+ if (timeoutId) {
65
+ clearTimeout(timeoutId);
66
+ timeoutId = null;
67
+ }
68
+ if (pendingReq) {
69
+ pendingReq.destroy();
70
+ pendingReq = null;
71
+ }
72
+ };
73
+ const checkReady = () => {
74
+ if (settled)
75
+ return;
76
+ if (Date.now() - startTime > timeoutMs) {
77
+ cleanup();
78
+ reject(new Error(`ttyd did not become ready within ${timeoutMs}ms`));
79
+ return;
80
+ }
81
+ const req = request({
82
+ hostname: "localhost",
83
+ port,
84
+ path: `/${sessionId}/`,
85
+ method: "GET",
86
+ timeout: 500,
87
+ }, (_res) => {
88
+ // Any response (even 404) means ttyd is listening
89
+ cleanup();
90
+ resolve();
91
+ });
92
+ pendingReq = req;
93
+ req.on("timeout", () => {
94
+ if (settled)
95
+ return;
96
+ req.destroy();
97
+ pendingReq = null;
98
+ // Schedule retry but track the timeout ID
99
+ timeoutId = setTimeout(checkReady, 100);
100
+ });
101
+ req.on("error", () => {
102
+ if (settled)
103
+ return;
104
+ pendingReq = null;
105
+ // Connection refused or other error - ttyd not ready yet, retry
106
+ timeoutId = setTimeout(checkReady, 100);
107
+ });
108
+ req.end();
109
+ };
110
+ checkReady();
111
+ });
112
+ }
113
+ /**
114
+ * Spawn or reuse a ttyd instance for a tmux session.
115
+ *
116
+ * @param sessionId - User-facing session ID (used for base-path and URL)
117
+ * @param tmuxSessionName - Actual tmux session name (may be hash-prefixed)
118
+ */
119
+ function getOrSpawnTtyd(sessionId, tmuxSessionName) {
120
+ const existing = instances.get(sessionId);
121
+ if (existing) {
122
+ metrics.totalReused += 1;
123
+ recordWebsocketMetric({
124
+ metric: "websocket_connect",
125
+ outcome: "success",
126
+ sessionId,
127
+ data: { reused: true, port: existing.port },
128
+ });
129
+ return existing;
130
+ }
131
+ // Allocate port: reuse from pool if available, otherwise increment
132
+ let port;
133
+ if (availablePorts.size > 0) {
134
+ // Reuse a recycled port
135
+ port = availablePorts.values().next().value;
136
+ availablePorts.delete(port);
137
+ }
138
+ else {
139
+ // Allocate new port
140
+ if (nextPort >= MAX_PORT) {
141
+ throw new Error(`Port exhaustion: reached maximum of ${MAX_PORT - 7800} terminal instances`);
142
+ }
143
+ port = nextPort++;
144
+ }
145
+ console.log(`[Terminal] Spawning ttyd for ${tmuxSessionName} on port ${port}`);
146
+ metrics.totalSpawns += 1;
147
+ metrics.lastSpawnAt = new Date().toISOString();
148
+ // Enable mouse mode for scrollback support
149
+ const mouseProc = spawn(TMUX, ["set-option", "-t", tmuxSessionName, "mouse", "on"]);
150
+ mouseProc.on("error", (err) => {
151
+ console.error(`[Terminal] Failed to set mouse mode for ${tmuxSessionName}:`, err.message);
152
+ });
153
+ // Hide the green status bar for cleaner appearance
154
+ const statusProc = spawn(TMUX, ["set-option", "-t", tmuxSessionName, "status", "off"]);
155
+ statusProc.on("error", (err) => {
156
+ console.error(`[Terminal] Failed to hide status bar for ${tmuxSessionName}:`, err.message);
157
+ });
158
+ // Use user-facing sessionId for base-path (matches URL the dashboard uses)
159
+ // Use tmuxSessionName for tmux attach (may be hash-prefixed)
160
+ const proc = spawn("ttyd", [
161
+ "--writable",
162
+ "--port",
163
+ String(port),
164
+ "--base-path",
165
+ `/${sessionId}`,
166
+ TMUX,
167
+ "attach-session",
168
+ "-t",
169
+ tmuxSessionName,
170
+ ], {
171
+ stdio: ["ignore", "pipe", "pipe"],
172
+ });
173
+ proc.stdout?.on("data", (data) => {
174
+ console.log(`[Terminal] ttyd ${sessionId}: ${data.toString().trim()}`);
175
+ });
176
+ proc.stderr?.on("data", (data) => {
177
+ console.log(`[Terminal] ttyd ${sessionId}: ${data.toString().trim()}`);
178
+ });
179
+ // Use once() for cleanup handlers to prevent race condition when both exit and error fire
180
+ proc.once("exit", (code) => {
181
+ console.log(`[Terminal] ttyd ${sessionId} exited with code ${code}`);
182
+ // Only delete if this is still the current instance (prevents race with error handler)
183
+ const current = instances.get(sessionId);
184
+ if (current?.process === proc) {
185
+ instances.delete(sessionId);
186
+ metrics.activeInstances = instances.size;
187
+ // Only recycle port on clean exit (code 0), not on errors
188
+ // Failed ttyd processes may leave ports in TIME_WAIT state
189
+ if (code === 0) {
190
+ availablePorts.add(port);
191
+ }
192
+ }
193
+ recordWebsocketMetric({
194
+ metric: "websocket_disconnect",
195
+ outcome: code === 0 ? "success" : "failure",
196
+ sessionId,
197
+ reason: `ttyd_exit:${code}`,
198
+ data: { port },
199
+ });
200
+ });
201
+ proc.once("error", (err) => {
202
+ console.error(`[Terminal] ttyd ${sessionId} error:`, err.message);
203
+ // Only delete if this is still the current instance (prevents race with exit handler)
204
+ const current = instances.get(sessionId);
205
+ if (current?.process === proc) {
206
+ instances.delete(sessionId);
207
+ metrics.activeInstances = instances.size;
208
+ // Don't recycle port on error - may still be in use or TIME_WAIT
209
+ }
210
+ metrics.totalErrors += 1;
211
+ metrics.lastErrorAt = new Date().toISOString();
212
+ metrics.lastErrorReason = err.message;
213
+ recordWebsocketMetric({
214
+ metric: "websocket_error",
215
+ outcome: "failure",
216
+ sessionId,
217
+ reason: err.message,
218
+ data: { port },
219
+ });
220
+ // Kill any running process
221
+ try {
222
+ proc.kill();
223
+ }
224
+ catch {
225
+ // Ignore kill errors if process already dead
226
+ }
227
+ });
228
+ const instance = { sessionId, port, process: proc };
229
+ instances.set(sessionId, instance);
230
+ metrics.activeInstances = instances.size;
231
+ recordWebsocketMetric({
232
+ metric: "websocket_connect",
233
+ outcome: "success",
234
+ sessionId,
235
+ data: { reused: false, port },
236
+ });
237
+ return instance;
238
+ }
239
+ // Simple HTTP API for the dashboard to request terminal URLs
240
+ const server = createServer(async (req, res) => {
241
+ const url = new URL(req.url ?? "/", "http://localhost");
242
+ // CORS for dashboard - allow requests from the same host as the dashboard
243
+ // TODO: Replace with proper session-based authentication
244
+ const origin = req.headers.origin;
245
+ if (origin && origin !== "null") {
246
+ // Extract hostname from origin and compare with request host
247
+ try {
248
+ const originUrl = new URL(origin);
249
+ const requestHost = req.headers.host;
250
+ // Allow if origin hostname matches request host (supports remote deployments)
251
+ if (requestHost && originUrl.hostname === requestHost.split(":")[0]) {
252
+ res.setHeader("Access-Control-Allow-Origin", origin);
253
+ }
254
+ }
255
+ catch {
256
+ // Invalid origin URL, don't set CORS header
257
+ }
258
+ }
259
+ else {
260
+ // Allow null origin (file:// or local HTML files)
261
+ res.setHeader("Access-Control-Allow-Origin", "*");
262
+ }
263
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
264
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
265
+ if (req.method === "OPTIONS") {
266
+ res.writeHead(204);
267
+ res.end();
268
+ return;
269
+ }
270
+ // GET /terminal?session=ao-1 → returns { url, port }
271
+ if (url.pathname === "/terminal") {
272
+ const sessionId = url.searchParams.get("session");
273
+ if (!sessionId) {
274
+ res.writeHead(400, { "Content-Type": "application/json" });
275
+ res.end(JSON.stringify({ error: "Missing session parameter" }));
276
+ recordWebsocketMetric({
277
+ metric: "websocket_error",
278
+ outcome: "failure",
279
+ reason: "Missing session parameter",
280
+ });
281
+ return;
282
+ }
283
+ // Validate session ID to prevent path traversal and injection
284
+ if (!validateSessionId(sessionId)) {
285
+ res.writeHead(400, { "Content-Type": "application/json" });
286
+ res.end(JSON.stringify({ error: "Invalid session ID" }));
287
+ recordWebsocketMetric({
288
+ metric: "websocket_error",
289
+ outcome: "failure",
290
+ sessionId,
291
+ reason: "Invalid session ID",
292
+ });
293
+ return;
294
+ }
295
+ // Resolve tmux session name: try exact match first, then suffix match
296
+ // (hash-prefixed sessions like "8474d6f29887-ao-15" are accessed by user-facing ID "ao-15")
297
+ const tmuxSessionId = resolveTmuxSession(sessionId, TMUX);
298
+ if (!tmuxSessionId) {
299
+ res.writeHead(404, { "Content-Type": "application/json" });
300
+ res.end(JSON.stringify({ error: "Session not found" }));
301
+ recordWebsocketMetric({
302
+ metric: "websocket_error",
303
+ outcome: "failure",
304
+ sessionId,
305
+ reason: "Session not found",
306
+ });
307
+ return;
308
+ }
309
+ // Spawn ttyd and wait for it to be ready (catch port exhaustion and startup failures)
310
+ try {
311
+ const instance = getOrSpawnTtyd(sessionId, tmuxSessionId);
312
+ await waitForTtyd(instance.port, sessionId);
313
+ // Use the request host to construct the terminal URL (supports remote access)
314
+ const host = req.headers.host ?? "localhost";
315
+ const protocol = req.headers["x-forwarded-proto"] ?? "http";
316
+ res.writeHead(200, { "Content-Type": "application/json" });
317
+ res.end(JSON.stringify({
318
+ url: `${protocol}://${host.split(":")[0]}:${instance.port}/${sessionId}/`,
319
+ port: instance.port,
320
+ sessionId,
321
+ }));
322
+ }
323
+ catch (err) {
324
+ const errorMsg = err instanceof Error ? err.message : String(err);
325
+ console.error(`[Terminal] Failed to start terminal for ${sessionId}:`, errorMsg);
326
+ metrics.totalErrors += 1;
327
+ metrics.lastErrorAt = new Date().toISOString();
328
+ metrics.lastErrorReason = errorMsg;
329
+ recordWebsocketMetric({
330
+ metric: "websocket_error",
331
+ outcome: "failure",
332
+ sessionId,
333
+ reason: errorMsg,
334
+ });
335
+ res.writeHead(503, { "Content-Type": "application/json" });
336
+ res.end(JSON.stringify({ error: "Failed to start terminal" }));
337
+ }
338
+ return;
339
+ }
340
+ // GET /health
341
+ if (url.pathname === "/health") {
342
+ res.writeHead(200, { "Content-Type": "application/json" });
343
+ res.end(JSON.stringify({
344
+ instances: Object.fromEntries([...instances.entries()].map(([id, inst]) => [id, { port: inst.port }])),
345
+ metrics,
346
+ }));
347
+ return;
348
+ }
349
+ res.writeHead(404);
350
+ res.end("Not found");
351
+ });
352
+ const PORT = parseInt(process.env.TERMINAL_PORT ?? "14800", 10);
353
+ server.listen(PORT, () => {
354
+ console.log(`[Terminal] Server listening on port ${PORT}`);
355
+ });
356
+ // Graceful shutdown — kill all ttyd instances
357
+ function shutdown(signal) {
358
+ console.log(`[Terminal] Received ${signal}, shutting down...`);
359
+ for (const [, instance] of instances) {
360
+ instance.process.kill();
361
+ }
362
+ server.close(() => {
363
+ console.log("[Terminal] Server closed");
364
+ process.exit(0);
365
+ });
366
+ // Force exit after 5s if graceful shutdown hangs
367
+ // Use unref() so this timer doesn't prevent process exit if server closes quickly
368
+ const forceExitTimer = setTimeout(() => {
369
+ console.error("[Terminal] Forced shutdown after timeout");
370
+ process.exit(1);
371
+ }, 5000);
372
+ forceExitTimer.unref();
373
+ }
374
+ process.on("SIGINT", () => shutdown("SIGINT"));
375
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared tmux utilities for terminal servers.
3
+ *
4
+ * Extracted from direct-terminal-ws.ts and terminal-websocket.ts
5
+ * so the logic can be properly unit tested.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ /** Session ID validation regex — alphanumeric, hyphens, underscores only */
9
+ export const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
10
+ /** Hash prefix pattern — 12-char lowercase hex, as generated by generateConfigHash */
11
+ const HASH_PREFIX_PATTERN = /^[a-f0-9]{12}-/;
12
+ /**
13
+ * Validate a session ID format.
14
+ * Prevents path traversal, shell injection, and other attacks.
15
+ */
16
+ export function validateSessionId(sessionId) {
17
+ return SESSION_ID_PATTERN.test(sessionId);
18
+ }
19
+ /**
20
+ * Find full path to tmux binary.
21
+ *
22
+ * Checks common installation locations because node-pty's posix_spawnp
23
+ * doesn't reliably inherit PATH, and some Node.js environments (e.g.,
24
+ * launched from GUI apps) have minimal PATH.
25
+ *
26
+ * @param execFn - Injectable execFileSync for testing. Defaults to child_process.execFileSync.
27
+ */
28
+ export function findTmux(execFn = execFileSync) {
29
+ const candidates = [
30
+ "/opt/homebrew/bin/tmux", // macOS ARM (Homebrew)
31
+ "/usr/local/bin/tmux", // macOS Intel (Homebrew)
32
+ "/usr/bin/tmux", // Linux
33
+ ];
34
+ for (const p of candidates) {
35
+ try {
36
+ execFn(p, ["-V"], { timeout: 5000 });
37
+ return p;
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ }
43
+ return "tmux"; // Fall back to bare name
44
+ }
45
+ /**
46
+ * Resolve a user-facing session ID to its actual tmux session name.
47
+ *
48
+ * The hash-based architecture prefixes tmux session names with a config hash
49
+ * (e.g., "8474d6f29887-ao-15" for user-facing "ao-15"). This function:
50
+ *
51
+ * 1. Tries exact match first using tmux's `=` prefix syntax to prevent
52
+ * prefix matching (where "ao-1" would incorrectly match "ao-15").
53
+ * 2. Falls back to listing all sessions and finding one with a 12-char hex
54
+ * prefix followed by the exact session ID (e.g., `{12-hex}-{sessionId}`).
55
+ *
56
+ * @param sessionId - User-facing session ID (e.g., "ao-15")
57
+ * @param tmuxPath - Full path to tmux binary
58
+ * @param execFn - Injectable execFileSync for testing. Defaults to child_process.execFileSync.
59
+ * @returns The actual tmux session name, or null if not found
60
+ */
61
+ export function resolveTmuxSession(sessionId, tmuxPath, execFn = execFileSync) {
62
+ // Try exact match first using = prefix for exact matching (e.g., "ao-orchestrator")
63
+ // Without =, tmux uses prefix matching: "ao-1" would match "ao-15"
64
+ try {
65
+ execFn(tmuxPath, ["has-session", "-t", `=${sessionId}`], { timeout: 5000 });
66
+ return sessionId;
67
+ }
68
+ catch {
69
+ // Not an exact match
70
+ }
71
+ // Search for hash-prefixed tmux session (e.g., "8474d6f29887-ao-15" for "ao-15")
72
+ // Validate the 12-char hex prefix to avoid ambiguous suffix matches where
73
+ // "hash-my-app-1" could falsely match a lookup for "app-1".
74
+ try {
75
+ const output = execFn(tmuxPath, ["list-sessions", "-F", "#{session_name}"], {
76
+ timeout: 5000,
77
+ encoding: "utf8",
78
+ });
79
+ const sessions = output.split("\n").filter(Boolean);
80
+ const match = sessions.find((s) => HASH_PREFIX_PATTERN.test(s) && s.substring(13) === sessionId);
81
+ if (match) {
82
+ return match;
83
+ }
84
+ }
85
+ catch {
86
+ // tmux not running or no sessions
87
+ }
88
+ return null;
89
+ }
package/next.config.js ADDED
@@ -0,0 +1,33 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ transpilePackages: [
4
+ "@aoagents/ao-core",
5
+ "@aoagents/ao-plugin-agent-claude-code",
6
+ "@aoagents/ao-plugin-agent-opencode",
7
+ "@aoagents/ao-plugin-runtime-tmux",
8
+ "@aoagents/ao-plugin-scm-github",
9
+ "@aoagents/ao-plugin-tracker-github",
10
+ "@aoagents/ao-plugin-tracker-linear",
11
+ "@aoagents/ao-plugin-workspace-worktree",
12
+ ],
13
+ async headers() {
14
+ return [
15
+ {
16
+ source: "/sw.js",
17
+ headers: [
18
+ { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
19
+ { key: "Service-Worker-Allowed", value: "/" },
20
+ ],
21
+ },
22
+ ];
23
+ },
24
+ };
25
+
26
+ // Only load bundle analyzer when ANALYZE=true (dev-only dependency)
27
+ let config = nextConfig;
28
+ if (process.env.ANALYZE === "true") {
29
+ const { default: bundleAnalyzer } = await import("@next/bundle-analyzer");
30
+ config = bundleAnalyzer({ enabled: true })(nextConfig);
31
+ }
32
+
33
+ export default config;
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@aoagents/ao-web",
3
+ "version": "0.2.2",
4
+ "description": "Web dashboard for agent-orchestrator",
5
+ "type": "module",
6
+ "files": [
7
+ ".next/server",
8
+ ".next/static",
9
+ ".next/*.json",
10
+ ".next/BUILD_ID",
11
+ "dist-server",
12
+ "next.config.js"
13
+ ],
14
+ "scripts": {
15
+ "dev": "concurrently \"npm:dev:next\" \"npm:dev:terminal\" \"npm:dev:direct-terminal\"",
16
+ "dev:next": "next dev -p ${PORT:-3000}",
17
+ "dev:terminal": "tsx watch server/terminal-websocket.ts",
18
+ "dev:direct-terminal": "tsx watch server/direct-terminal-ws.ts",
19
+ "build": "next build && tsc -p tsconfig.server.json",
20
+ "start": "next start",
21
+ "start:all": "node dist-server/start-all.js",
22
+ "dev:optimized": "next build && tsc -p tsconfig.server.json && node dist-server/start-all.js",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest",
26
+ "clean": "rm -rf .next dist-server",
27
+ "screenshot": "tsx e2e/screenshot.ts",
28
+ "screenshot:install": "npx playwright install chromium"
29
+ },
30
+ "dependencies": {
31
+ "@aoagents/ao-core": "workspace:*",
32
+ "@aoagents/ao-plugin-agent-claude-code": "workspace:*",
33
+ "@aoagents/ao-plugin-agent-opencode": "workspace:*",
34
+ "@aoagents/ao-plugin-runtime-tmux": "workspace:*",
35
+ "@aoagents/ao-plugin-scm-github": "workspace:*",
36
+ "@aoagents/ao-plugin-tracker-github": "workspace:*",
37
+ "@aoagents/ao-plugin-tracker-linear": "workspace:*",
38
+ "@aoagents/ao-plugin-workspace-worktree": "workspace:*",
39
+ "@xterm/addon-fit": "^0.11.0",
40
+ "@xterm/addon-web-links": "^0.12.0",
41
+ "next": "^15.1.0",
42
+ "next-themes": "^0.4.6",
43
+ "react": "^19.0.0",
44
+ "react-dom": "^19.0.0",
45
+ "server-only": "^0.0.1",
46
+ "ws": "^8.19.0",
47
+ "xterm": "^5.3.0"
48
+ },
49
+ "optionalDependencies": {
50
+ "node-pty": "^1.1.0"
51
+ },
52
+ "devDependencies": {
53
+ "@next/bundle-analyzer": "^15.1.0",
54
+ "@tailwindcss/postcss": "^4.0.0",
55
+ "@testing-library/jest-dom": "^6.9.1",
56
+ "@testing-library/react": "^16.1.0",
57
+ "@types/react": "^19.0.0",
58
+ "@types/react-dom": "^19.0.0",
59
+ "@types/ws": "^8.18.1",
60
+ "@vitejs/plugin-react": "^4.3.0",
61
+ "@vitest/coverage-v8": "^2.1.0",
62
+ "concurrently": "^9.2.1",
63
+ "jsdom": "^25.0.0",
64
+ "node-gyp": "^12.2.0",
65
+ "playwright": "^1.49.0",
66
+ "tailwindcss": "^4.0.0",
67
+ "tsx": "^4.19.0",
68
+ "typescript": "^5.7.0",
69
+ "vitest": "^2.1.0"
70
+ }
71
+ }