@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.
- package/.next/BUILD_ID +1 -0
- package/.next/app-build-manifest.json +293 -0
- package/.next/app-path-routes-manifest.json +33 -0
- package/.next/build-manifest.json +33 -0
- package/.next/export-marker.json +6 -0
- package/.next/images-manifest.json +58 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +172 -0
- package/.next/react-loadable-manifest.json +47 -0
- package/.next/required-server-files.json +330 -0
- package/.next/routes-manifest.json +195 -0
- package/.next/server/app/_not-found/page.js +2 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +8 -0
- package/.next/server/app/_not-found.rsc +23 -0
- package/.next/server/app/api/backlog/route.js +1 -0
- package/.next/server/app/api/backlog/route.js.nft.json +1 -0
- package/.next/server/app/api/backlog/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/events/route.js +9 -0
- package/.next/server/app/api/events/route.js.nft.json +1 -0
- package/.next/server/app/api/events/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/issues/route.js +1 -0
- package/.next/server/app/api/issues/route.js.nft.json +1 -0
- package/.next/server/app/api/issues/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/observability/route.js +1 -0
- package/.next/server/app/api/observability/route.js.nft.json +1 -0
- package/.next/server/app/api/observability/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/orchestrators/route.js +1 -0
- package/.next/server/app/api/orchestrators/route.js.nft.json +1 -0
- package/.next/server/app/api/orchestrators/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/projects/route.js +1 -0
- package/.next/server/app/api/projects/route.js.nft.json +1 -0
- package/.next/server/app/api/projects/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/prs/[id]/merge/route.js +1 -0
- package/.next/server/app/api/prs/[id]/merge/route.js.nft.json +1 -0
- package/.next/server/app/api/prs/[id]/merge/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/runtime/terminal/route.js +1 -0
- package/.next/server/app/api/runtime/terminal/route.js.nft.json +1 -0
- package/.next/server/app/api/runtime/terminal/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/sessions/[id]/kill/route.js +1 -0
- package/.next/server/app/api/sessions/[id]/kill/route.js.nft.json +1 -0
- package/.next/server/app/api/sessions/[id]/kill/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/sessions/[id]/message/route.js +1 -0
- package/.next/server/app/api/sessions/[id]/message/route.js.nft.json +1 -0
- package/.next/server/app/api/sessions/[id]/message/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/sessions/[id]/remap/route.js +1 -0
- package/.next/server/app/api/sessions/[id]/remap/route.js.nft.json +1 -0
- package/.next/server/app/api/sessions/[id]/remap/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/sessions/[id]/restore/route.js +1 -0
- package/.next/server/app/api/sessions/[id]/restore/route.js.nft.json +1 -0
- package/.next/server/app/api/sessions/[id]/restore/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/sessions/[id]/route.js +1 -0
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -0
- package/.next/server/app/api/sessions/[id]/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/sessions/[id]/send/route.js +1 -0
- package/.next/server/app/api/sessions/[id]/send/route.js.nft.json +1 -0
- package/.next/server/app/api/sessions/[id]/send/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/sessions/route.js +1 -0
- package/.next/server/app/api/sessions/route.js.nft.json +1 -0
- package/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/setup-labels/route.js +1 -0
- package/.next/server/app/api/setup-labels/route.js.nft.json +1 -0
- package/.next/server/app/api/setup-labels/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/spawn/route.js +1 -0
- package/.next/server/app/api/spawn/route.js.nft.json +1 -0
- package/.next/server/app/api/spawn/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/verify/route.js +1 -0
- package/.next/server/app/api/verify/route.js.nft.json +1 -0
- package/.next/server/app/api/verify/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/webhooks/[...slug]/route.js +1 -0
- package/.next/server/app/api/webhooks/[...slug]/route.js.nft.json +1 -0
- package/.next/server/app/api/webhooks/[...slug]/route_client-reference-manifest.js +1 -0
- package/.next/server/app/apple-icon/route.js +1 -0
- package/.next/server/app/apple-icon/route.js.nft.json +1 -0
- package/.next/server/app/apple-icon/route_client-reference-manifest.js +1 -0
- package/.next/server/app/apple-icon.body +0 -0
- package/.next/server/app/apple-icon.meta +1 -0
- package/.next/server/app/dev/terminal-test/page.js +15 -0
- package/.next/server/app/dev/terminal-test/page.js.nft.json +1 -0
- package/.next/server/app/dev/terminal-test/page_client-reference-manifest.js +1 -0
- package/.next/server/app/dev/terminal-test.html +1 -0
- package/.next/server/app/dev/terminal-test.meta +7 -0
- package/.next/server/app/dev/terminal-test.rsc +27 -0
- package/.next/server/app/icon/route.js +1 -0
- package/.next/server/app/icon/route.js.nft.json +1 -0
- package/.next/server/app/icon/route_client-reference-manifest.js +1 -0
- package/.next/server/app/icon-192/route.js +1 -0
- package/.next/server/app/icon-192/route.js.nft.json +1 -0
- package/.next/server/app/icon-192/route_client-reference-manifest.js +1 -0
- package/.next/server/app/icon-512/route.js +1 -0
- package/.next/server/app/icon-512/route.js.nft.json +1 -0
- package/.next/server/app/icon-512/route_client-reference-manifest.js +1 -0
- package/.next/server/app/icon.body +0 -0
- package/.next/server/app/icon.meta +1 -0
- package/.next/server/app/manifest.webmanifest/route.js +16 -0
- package/.next/server/app/manifest.webmanifest/route.js.nft.json +1 -0
- package/.next/server/app/manifest.webmanifest/route_client-reference-manifest.js +1 -0
- package/.next/server/app/manifest.webmanifest.body +1 -0
- package/.next/server/app/manifest.webmanifest.meta +1 -0
- package/.next/server/app/orchestrators/page.js +2 -0
- package/.next/server/app/orchestrators/page.js.nft.json +1 -0
- package/.next/server/app/orchestrators/page_client-reference-manifest.js +1 -0
- package/.next/server/app/page.js +2 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app/prs/page.js +2 -0
- package/.next/server/app/prs/page.js.nft.json +1 -0
- package/.next/server/app/prs/page_client-reference-manifest.js +1 -0
- package/.next/server/app/sessions/[id]/page.js +12 -0
- package/.next/server/app/sessions/[id]/page.js.nft.json +1 -0
- package/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -0
- package/.next/server/app/test-direct/page.js +2 -0
- package/.next/server/app/test-direct/page.js.nft.json +1 -0
- package/.next/server/app/test-direct/page_client-reference-manifest.js +1 -0
- package/.next/server/app/test-direct.html +1 -0
- package/.next/server/app/test-direct.meta +7 -0
- package/.next/server/app/test-direct.rsc +27 -0
- package/.next/server/app-paths-manifest.json +33 -0
- package/.next/server/chunks/27.js +438 -0
- package/.next/server/chunks/377.js +1 -0
- package/.next/server/chunks/393.js +1 -0
- package/.next/server/chunks/627.js +391 -0
- package/.next/server/chunks/639.js +6 -0
- package/.next/server/chunks/693.js +1 -0
- package/.next/server/chunks/705.js +22 -0
- package/.next/server/chunks/787.js +29 -0
- package/.next/server/chunks/796.js +1 -0
- package/.next/server/chunks/934.js +1 -0
- package/.next/server/chunks/956.js +9 -0
- package/.next/server/functions-config-manifest.json +4 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +6 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +19 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +6 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/3WYmn9ZvJ5NqH2dUKVdTc/_buildManifest.js +1 -0
- package/.next/static/3WYmn9ZvJ5NqH2dUKVdTc/_ssgManifest.js +1 -0
- package/.next/static/chunks/1250-e7cf6b069fbb03ed.js +1 -0
- package/.next/static/chunks/2205.498806f73783aa54.js +1 -0
- package/.next/static/chunks/3698-9c12c45b8184022c.js +1 -0
- package/.next/static/chunks/6381.1541d5695a727108.js +1 -0
- package/.next/static/chunks/7411.ecda44797fb514a0.js +1 -0
- package/.next/static/chunks/7505-2d2422d31862995f.js +1 -0
- package/.next/static/chunks/8597-1385f90ec1cebf47.js +1 -0
- package/.next/static/chunks/8762.f3d526855363db16.js +1 -0
- package/.next/static/chunks/9393.acf1934a190d793b.js +1 -0
- package/.next/static/chunks/a51c26f2-a21e680a5df6764e.js +1 -0
- package/.next/static/chunks/app/_not-found/page-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/backlog/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/events/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/issues/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/observability/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/orchestrators/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/projects/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/prs/[id]/merge/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/runtime/terminal/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/kill/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/message/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/remap/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/restore/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/send/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/setup-labels/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/spawn/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/verify/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/api/webhooks/[...slug]/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/apple-icon/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/dev/terminal-test/page-ac0ce5b046fcad82.js +1 -0
- package/.next/static/chunks/app/error-4896c9d3b7681a80.js +1 -0
- package/.next/static/chunks/app/global-error-7b5c8ae45329c659.js +1 -0
- package/.next/static/chunks/app/icon/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/icon-192/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/icon-512/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/layout-023f2083204e4f68.js +1 -0
- package/.next/static/chunks/app/loading-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/manifest.webmanifest/route-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/not-found-3772c2e09c29d80f.js +1 -0
- package/.next/static/chunks/app/orchestrators/page-df85236df674fc5a.js +1 -0
- package/.next/static/chunks/app/page-e97da633b7ef25f2.js +1 -0
- package/.next/static/chunks/app/prs/page-8cc0fce584d238c5.js +1 -0
- package/.next/static/chunks/app/sessions/[id]/error-90bc99b777fecabf.js +1 -0
- package/.next/static/chunks/app/sessions/[id]/loading-2224bc1d3dce1b3e.js +1 -0
- package/.next/static/chunks/app/sessions/[id]/not-found-3772c2e09c29d80f.js +1 -0
- package/.next/static/chunks/app/sessions/[id]/page-c9c95a8604786cff.js +1 -0
- package/.next/static/chunks/app/test-direct/page-16ceeb9f7664394a.js +1 -0
- package/.next/static/chunks/df4ed4d4.6a752eba3933e9a8.js +3 -0
- package/.next/static/chunks/framework-f8b8ba0f71d38056.js +1 -0
- package/.next/static/chunks/main-2bc85c765bf1fc49.js +1 -0
- package/.next/static/chunks/main-app-93fb36c3bd1a6739.js +1 -0
- package/.next/static/chunks/pages/_app-a0b975794f15bd68.js +1 -0
- package/.next/static/chunks/pages/_error-5f4e3b5eea57917d.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-e12ceebeb7a1cc7e.js +1 -0
- package/.next/static/css/46a7e8e962075e54.css +1 -0
- package/.next/static/css/6ef2fa08dd043252.css +1 -0
- package/.next/static/css/908f93fdd7ffba42.css +1 -0
- package/.next/static/media/4cf2300e9c8272f7-s.p.woff2 +0 -0
- package/.next/static/media/558ca1a6aa3cb55e-s.p.woff2 +0 -0
- package/.next/static/media/64d784ea54a4acde-s.woff2 +0 -0
- package/.next/static/media/6d831b18ae5b01dc-s.woff2 +0 -0
- package/.next/static/media/8d697b304b401681-s.woff2 +0 -0
- package/.next/static/media/ac0e76ddaeeb7981-s.woff2 +0 -0
- package/.next/static/media/ba015fad6dcf6784-s.woff2 +0 -0
- package/.next/static/media/edc640959b0c7826-s.woff2 +0 -0
- package/.next/static/media/ff71da380fbe67dd-s.woff2 +0 -0
- package/dist-server/direct-terminal-ws.js +297 -0
- package/dist-server/start-all.js +113 -0
- package/dist-server/terminal-observability.js +16 -0
- package/dist-server/terminal-websocket.js +375 -0
- package/dist-server/tmux-utils.js +89 -0
- package/next.config.js +33 -0
- 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
|
+
}
|