ai-cli-online 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/README.zh-CN.md +170 -0
  4. package/bin/ai-cli-online.mjs +89 -0
  5. package/install-service.sh +319 -0
  6. package/package.json +57 -0
  7. package/server/.env.example +18 -0
  8. package/server/dist/auth.d.ts +3 -0
  9. package/server/dist/auth.js +9 -0
  10. package/server/dist/claude.d.ts +16 -0
  11. package/server/dist/claude.js +141 -0
  12. package/server/dist/db.d.ts +7 -0
  13. package/server/dist/db.js +73 -0
  14. package/server/dist/files.d.ts +15 -0
  15. package/server/dist/files.js +56 -0
  16. package/server/dist/index.d.ts +1 -0
  17. package/server/dist/index.js +466 -0
  18. package/server/dist/plans.d.ts +7 -0
  19. package/server/dist/plans.js +120 -0
  20. package/server/dist/pty.d.ts +15 -0
  21. package/server/dist/pty.js +75 -0
  22. package/server/dist/storage.d.ts +22 -0
  23. package/server/dist/storage.js +149 -0
  24. package/server/dist/tmux.d.ts +40 -0
  25. package/server/dist/tmux.js +191 -0
  26. package/server/dist/types.d.ts +1 -0
  27. package/server/dist/types.js +1 -0
  28. package/server/dist/websocket.d.ts +4 -0
  29. package/server/dist/websocket.js +304 -0
  30. package/server/package.json +32 -0
  31. package/shared/dist/types.d.ts +40 -0
  32. package/shared/dist/types.js +1 -0
  33. package/shared/package.json +20 -0
  34. package/start.sh +39 -0
  35. package/web/dist/assets/index-79TY7o1G.css +32 -0
  36. package/web/dist/assets/index-mcWZLwbP.js +235 -0
  37. package/web/dist/assets/pdf-Tk4_4Bu3.js +12 -0
  38. package/web/dist/assets/pdf.worker-BA9kU3Pw.mjs +61080 -0
  39. package/web/dist/favicon.svg +5 -0
  40. package/web/dist/fonts/JetBrainsMono-Bold.woff2 +0 -0
  41. package/web/dist/fonts/JetBrainsMono-Regular.woff2 +0 -0
  42. package/web/dist/fonts/MapleMono-CN-Bold.woff2 +0 -0
  43. package/web/dist/fonts/MapleMono-CN-Regular.woff2 +0 -0
  44. package/web/dist/index.html +17 -0
  45. package/web/package.json +32 -0
@@ -0,0 +1,304 @@
1
+ import { WebSocket } from 'ws';
2
+ import { buildSessionName, isValidSessionId, tokenToSessionName, hasSession, createSession, captureScrollback, resizeSession, } from './tmux.js';
3
+ import { PtySession } from './pty.js';
4
+ /**
5
+ * Binary protocol for hot-path messages (output/input/scrollback).
6
+ * Format: [1-byte type prefix][raw UTF-8 payload]
7
+ * JSON is kept for low-frequency control messages.
8
+ */
9
+ const BIN_TYPE_OUTPUT = 0x01;
10
+ const BIN_TYPE_INPUT = 0x02;
11
+ const BIN_TYPE_SCROLLBACK = 0x03;
12
+ const BIN_TYPE_SCROLLBACK_CONTENT = 0x04;
13
+ /** Track active connections per session name to prevent duplicates */
14
+ const activeConnections = new Map();
15
+ /** Rate-limit failed WebSocket auth attempts per IP */
16
+ const authFailures = new Map();
17
+ const AUTH_FAIL_MAX = 5;
18
+ const AUTH_FAIL_WINDOW_MS = 60_000;
19
+ // Periodically prune expired entries to prevent unbounded memory growth
20
+ setInterval(() => {
21
+ const now = Date.now();
22
+ for (const [ip, entry] of authFailures) {
23
+ if (now > entry.resetAt)
24
+ authFailures.delete(ip);
25
+ }
26
+ }, 5 * 60_000);
27
+ function isAuthRateLimited(ip) {
28
+ const now = Date.now();
29
+ const entry = authFailures.get(ip);
30
+ if (!entry || now > entry.resetAt)
31
+ return false;
32
+ return entry.count >= AUTH_FAIL_MAX;
33
+ }
34
+ function recordAuthFailure(ip) {
35
+ const now = Date.now();
36
+ const entry = authFailures.get(ip);
37
+ if (!entry || now > entry.resetAt) {
38
+ authFailures.set(ip, { count: 1, resetAt: now + AUTH_FAIL_WINDOW_MS });
39
+ }
40
+ else {
41
+ entry.count++;
42
+ }
43
+ }
44
+ /** Count active connections for a given token prefix */
45
+ function countConnectionsForToken(tokenPrefix) {
46
+ let count = 0;
47
+ for (const [name, ws] of activeConnections) {
48
+ if (name.startsWith(tokenPrefix) && ws.readyState === WebSocket.OPEN) {
49
+ count++;
50
+ }
51
+ }
52
+ return count;
53
+ }
54
+ /** Get the set of session names with active open WebSocket connections */
55
+ export function getActiveSessionNames() {
56
+ const names = new Set();
57
+ for (const [name, ws] of activeConnections) {
58
+ if (ws.readyState === WebSocket.OPEN) {
59
+ names.add(name);
60
+ }
61
+ }
62
+ return names;
63
+ }
64
+ /** Send a JSON control message (low-frequency: connected, error, pong) */
65
+ function send(ws, msg) {
66
+ if (ws.readyState === WebSocket.OPEN) {
67
+ ws.send(JSON.stringify(msg));
68
+ }
69
+ }
70
+ /** Send binary data with a 1-byte type prefix (high-frequency hot path) */
71
+ function sendBinary(ws, typePrefix, data) {
72
+ if (ws.readyState === WebSocket.OPEN) {
73
+ const byteLen = Buffer.byteLength(data, 'utf-8');
74
+ const buf = Buffer.allocUnsafe(1 + byteLen);
75
+ buf[0] = typePrefix;
76
+ buf.write(data, 1, byteLen, 'utf-8');
77
+ ws.send(buf);
78
+ }
79
+ }
80
+ /** Server-side keepalive: ping all clients every 30s, terminate if no pong */
81
+ function startKeepAlive(wss) {
82
+ const KEEPALIVE_INTERVAL = 30_000;
83
+ setInterval(() => {
84
+ for (const ws of wss.clients) {
85
+ const alive = ws;
86
+ if (alive._isAlive === false) {
87
+ // No pong received since last ping — terminate
88
+ console.log('[WS] Keepalive: terminating unresponsive connection');
89
+ alive.terminate();
90
+ continue;
91
+ }
92
+ alive._isAlive = false;
93
+ alive.ping();
94
+ }
95
+ }, KEEPALIVE_INTERVAL);
96
+ wss.on('connection', (ws) => {
97
+ ws._isAlive = true;
98
+ ws.on('pong', () => {
99
+ ws._isAlive = true;
100
+ });
101
+ });
102
+ }
103
+ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConnections = 10) {
104
+ // Start server-side keepalive to detect dead connections
105
+ startKeepAlive(wss);
106
+ const compareToken = tokenCompare;
107
+ wss.on('connection', (ws, req) => {
108
+ // Disable Nagle algorithm for low-latency terminal I/O (eliminates up to 40ms delay per keystroke)
109
+ const socket = req.socket;
110
+ if (socket && typeof socket.setNoDelay === 'function') {
111
+ socket.setNoDelay(true);
112
+ }
113
+ const clientIp = req.socket.remoteAddress || 'unknown';
114
+ // Reject connections from IPs with too many recent auth failures
115
+ if (authToken && isAuthRateLimited(clientIp)) {
116
+ console.log(`[WS] Auth rate-limited IP: ${clientIp}`);
117
+ ws.close(4001, 'Too many auth failures');
118
+ return;
119
+ }
120
+ const url = new URL(req.url || '', `http://${req.headers.host}`);
121
+ const cols = 80;
122
+ const rows = 24;
123
+ const rawSessionId = url.searchParams.get('sessionId') || undefined;
124
+ const sessionId = rawSessionId && isValidSessionId(rawSessionId) ? rawSessionId : undefined;
125
+ if (rawSessionId && !sessionId) {
126
+ console.log(`[WS] Invalid sessionId rejected: ${rawSessionId}`);
127
+ ws.close(4004, 'Invalid sessionId');
128
+ return;
129
+ }
130
+ // First-message auth: wait for { type: 'auth', token } before setting up session.
131
+ // A 5-second timeout ensures unauthenticated connections don't linger.
132
+ let authenticated = !authToken; // skip auth if no token configured
133
+ let sessionName = '';
134
+ let ptySession = null;
135
+ let sessionInitializing = false; // guard against concurrent initSession calls
136
+ let lastScrollbackTime = 0; // throttle capture-scrollback requests
137
+ const SCROLLBACK_THROTTLE_MS = 2000;
138
+ const AUTH_TIMEOUT = 5000;
139
+ const authTimer = authToken
140
+ ? setTimeout(() => {
141
+ if (!authenticated) {
142
+ console.log('[WS] Auth timeout — no auth message received');
143
+ ws.close(4001, 'Auth timeout');
144
+ }
145
+ }, AUTH_TIMEOUT)
146
+ : null;
147
+ async function initSession(token) {
148
+ if (sessionInitializing || ptySession)
149
+ return; // prevent double init
150
+ sessionInitializing = true;
151
+ try {
152
+ sessionName = buildSessionName(token, sessionId);
153
+ // Connection limit per token
154
+ const tokenPrefix = tokenToSessionName(token) + '-';
155
+ if (countConnectionsForToken(tokenPrefix) >= maxConnections) {
156
+ console.log(`[WS] Connection limit (${maxConnections}) reached for token`);
157
+ ws.close(4005, 'Too many connections');
158
+ return;
159
+ }
160
+ console.log(`[WS] Client connected, session: ${sessionName}, size: ${cols}x${rows}`);
161
+ // Kick duplicate connection for same session
162
+ const existing = activeConnections.get(sessionName);
163
+ if (existing && existing.readyState === WebSocket.OPEN) {
164
+ console.log(`[WS] Kicking existing connection for session: ${sessionName}`);
165
+ existing.close(4002, 'Replaced by new connection');
166
+ }
167
+ activeConnections.set(sessionName, ws);
168
+ // Check or create tmux session
169
+ const resumed = await hasSession(sessionName);
170
+ if (!resumed) {
171
+ await createSession(sessionName, cols, rows, defaultCwd);
172
+ }
173
+ else {
174
+ // resizeSession and captureScrollback are independent — run in parallel
175
+ const [, scrollback] = await Promise.all([
176
+ resizeSession(sessionName, cols, rows),
177
+ captureScrollback(sessionName),
178
+ ]);
179
+ if (scrollback) {
180
+ sendBinary(ws, BIN_TYPE_SCROLLBACK, scrollback);
181
+ }
182
+ }
183
+ send(ws, { type: 'connected', resumed });
184
+ // Attach PTY to tmux session
185
+ try {
186
+ ptySession = new PtySession(sessionName, cols, rows);
187
+ }
188
+ catch (err) {
189
+ console.error(`[WS] Failed to attach PTY to session ${sessionName}:`, err);
190
+ send(ws, { type: 'error', error: 'Failed to attach to terminal session' });
191
+ ws.close(4003, 'PTY attach failed');
192
+ return;
193
+ }
194
+ ptySession.onData((data) => {
195
+ sendBinary(ws, BIN_TYPE_OUTPUT, data);
196
+ });
197
+ ptySession.onExit((code, signal) => {
198
+ console.log(`[WS] PTY exited for session ${sessionName}, code: ${code}, signal: ${signal}`);
199
+ if (ws.readyState === WebSocket.OPEN) {
200
+ ws.close(1000, 'PTY exited');
201
+ }
202
+ });
203
+ }
204
+ catch (err) {
205
+ console.error(`[WS] initSession failed for ${sessionName}:`, err);
206
+ ws.close(4003, 'Session init failed');
207
+ }
208
+ finally {
209
+ sessionInitializing = false;
210
+ }
211
+ }
212
+ // If no auth required, init immediately with default token
213
+ if (authenticated) {
214
+ initSession('default');
215
+ }
216
+ ws.on('message', async (raw, isBinary) => {
217
+ try {
218
+ // Binary hot-path: [1-byte type][payload]
219
+ if (isBinary && Buffer.isBuffer(raw) && raw.length >= 1) {
220
+ if (!authenticated) {
221
+ ws.close(4001, 'Auth required');
222
+ return;
223
+ }
224
+ const typePrefix = raw[0];
225
+ if (typePrefix === BIN_TYPE_INPUT) {
226
+ const data = raw.subarray(1).toString('utf-8');
227
+ ptySession?.write(data);
228
+ }
229
+ return;
230
+ }
231
+ // JSON control messages
232
+ const msg = JSON.parse(raw.toString());
233
+ // Handle auth message (must be first message when auth is enabled)
234
+ if (msg.type === 'auth') {
235
+ if (authenticated)
236
+ return; // already authenticated, ignore
237
+ if (authTimer)
238
+ clearTimeout(authTimer);
239
+ if (!msg.token || !compareToken(msg.token, authToken)) {
240
+ recordAuthFailure(clientIp);
241
+ console.log(`[WS] Unauthorized — invalid token from ${clientIp}`);
242
+ ws.close(4001, 'Unauthorized');
243
+ return;
244
+ }
245
+ authenticated = true;
246
+ await initSession(msg.token);
247
+ return;
248
+ }
249
+ // All other messages require authentication
250
+ if (!authenticated) {
251
+ ws.close(4001, 'Auth required');
252
+ return;
253
+ }
254
+ switch (msg.type) {
255
+ case 'input':
256
+ // Legacy JSON input support (fallback)
257
+ ptySession?.write(msg.data);
258
+ break;
259
+ case 'resize': {
260
+ const c = Math.max(1, Math.min(500, Math.floor(msg.cols || 80)));
261
+ const r = Math.max(1, Math.min(500, Math.floor(msg.rows || 24)));
262
+ // PTY resize (sync) and tmux resize (async subprocess) are independent — run in parallel
263
+ ptySession?.resize(c, r);
264
+ resizeSession(sessionName, c, r);
265
+ break;
266
+ }
267
+ case 'ping':
268
+ send(ws, { type: 'pong', timestamp: Date.now() });
269
+ break;
270
+ case 'capture-scrollback': {
271
+ // Throttle to prevent abuse (subprocess spawning is expensive)
272
+ const now = Date.now();
273
+ if (now - lastScrollbackTime < SCROLLBACK_THROTTLE_MS)
274
+ break;
275
+ lastScrollbackTime = now;
276
+ const content = await captureScrollback(sessionName);
277
+ // Normalize newlines server-side to avoid client main-thread regex on large strings
278
+ const normalized = content.replace(/\n/g, '\r\n');
279
+ sendBinary(ws, BIN_TYPE_SCROLLBACK_CONTENT, normalized);
280
+ break;
281
+ }
282
+ }
283
+ }
284
+ catch {
285
+ // Ignore malformed messages
286
+ }
287
+ });
288
+ ws.on('close', () => {
289
+ if (authTimer)
290
+ clearTimeout(authTimer);
291
+ if (sessionName) {
292
+ console.log(`[WS] Client disconnected, session: ${sessionName}`);
293
+ if (activeConnections.get(sessionName) === ws) {
294
+ activeConnections.delete(sessionName);
295
+ }
296
+ }
297
+ ptySession?.kill();
298
+ ptySession = null;
299
+ });
300
+ ws.on('error', (err) => {
301
+ console.error(`[WS] Error${sessionName ? ` for session ${sessionName}` : ''}:`, err);
302
+ });
303
+ });
304
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "ai-cli-online-server",
3
+ "version": "2.0.0",
4
+ "description": "CLI-Online Backend Server",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "tsx watch src/index.ts",
9
+ "build": "tsc",
10
+ "start": "node dist/index.js"
11
+ },
12
+ "dependencies": {
13
+ "better-sqlite3": "^12.6.2",
14
+ "ai-cli-online-shared": "*",
15
+ "dotenv": "^16.3.1",
16
+ "express": "^4.18.2",
17
+ "express-rate-limit": "^8.2.1",
18
+ "helmet": "^8.1.0",
19
+ "multer": "^2.0.2",
20
+ "node-pty": "^1.1.0",
21
+ "ws": "^8.16.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/better-sqlite3": "^7.6.13",
25
+ "@types/express": "^4.17.21",
26
+ "@types/multer": "^2.0.0",
27
+ "@types/node": "^20.10.0",
28
+ "@types/ws": "^8.5.10",
29
+ "tsx": "^4.7.0",
30
+ "typescript": "^5.9.3"
31
+ }
32
+ }
@@ -0,0 +1,40 @@
1
+ export interface FileEntry {
2
+ name: string;
3
+ type: 'file' | 'directory';
4
+ size: number;
5
+ modifiedAt: string;
6
+ }
7
+ export type ClientMessage = {
8
+ type: 'auth';
9
+ token: string;
10
+ } | {
11
+ type: 'input';
12
+ data: string;
13
+ } | {
14
+ type: 'resize';
15
+ cols: number;
16
+ rows: number;
17
+ } | {
18
+ type: 'ping';
19
+ } | {
20
+ type: 'capture-scrollback';
21
+ };
22
+ export type ServerMessage = {
23
+ type: 'output';
24
+ data: string;
25
+ } | {
26
+ type: 'scrollback';
27
+ data: string;
28
+ } | {
29
+ type: 'scrollback-content';
30
+ data: string;
31
+ } | {
32
+ type: 'connected';
33
+ resumed: boolean;
34
+ } | {
35
+ type: 'error';
36
+ error: string;
37
+ } | {
38
+ type: 'pong';
39
+ timestamp: number;
40
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "ai-cli-online-shared",
3
+ "version": "2.0.0",
4
+ "description": "Shared types for CLI-Online",
5
+ "type": "module",
6
+ "main": "dist/types.js",
7
+ "types": "dist/types.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/types.d.ts",
11
+ "import": "./dist/types.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.9.3"
19
+ }
20
+ }
package/start.sh ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ PORT="${PORT:-3001}"
6
+
7
+ echo "================================"
8
+ echo " AI-CLI-Online 启动脚本"
9
+ echo "================================"
10
+
11
+ # 1. 清理占用端口的旧进程
12
+ if fuser "$PORT/tcp" >/dev/null 2>&1; then
13
+ echo "[清理] 端口 $PORT 被占用,正在终止旧进程..."
14
+ fuser -k "$PORT/tcp" >/dev/null 2>&1 || true
15
+ sleep 1
16
+ # 如果 SIGTERM 没杀掉,强制 kill
17
+ if fuser "$PORT/tcp" >/dev/null 2>&1; then
18
+ echo "[清理] 旧进程未响应,强制终止..."
19
+ fuser -k -9 "$PORT/tcp" >/dev/null 2>&1 || true
20
+ sleep 1
21
+ fi
22
+ echo "[清理] 旧进程已终止"
23
+ else
24
+ echo "[清理] 端口 $PORT 空闲,无需清理"
25
+ fi
26
+
27
+ # 2. 清理残留的 node 子进程(仅限本项目)
28
+ pkill -f "node.*ai-cli-online.*dist/index.js" 2>/dev/null || true
29
+
30
+ # 3. 构建项目
31
+ echo "[构建] 编译 server 和 web..."
32
+ cd "$PROJECT_DIR"
33
+ npm run build 2>&1
34
+ echo "[构建] 完成"
35
+
36
+ # 4. 启动服务(从 server/ 目录启动,确保 dotenv 能读取 server/.env)
37
+ echo "[启动] 启动服务 (端口: $PORT)..."
38
+ cd "$PROJECT_DIR/server"
39
+ exec node dist/index.js
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Copyright (c) 2014 The xterm.js authors. All rights reserved.
3
+ * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
4
+ * https://github.com/chjj/term.js
5
+ * @license MIT
6
+ *
7
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ * of this software and associated documentation files (the "Software"), to deal
9
+ * in the Software without restriction, including without limitation the rights
10
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ * copies of the Software, and to permit persons to whom the Software is
12
+ * furnished to do so, subject to the following conditions:
13
+ *
14
+ * The above copyright notice and this permission notice shall be included in
15
+ * all copies or substantial portions of the Software.
16
+ *
17
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
+ * THE SOFTWARE.
24
+ *
25
+ * Originally forked from (with the author's permission):
26
+ * Fabrice Bellard's javascript vt100 for jslinux:
27
+ * http://bellard.org/jslinux/
28
+ * Copyright (c) 2011 Fabrice Bellard
29
+ * The original design remains. The terminal itself
30
+ * has been extended to include xterm CSI codes, among
31
+ * other features.
32
+ */.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) *::selection{color:transparent}.xterm .xterm-accessibility-tree{font-family:monospace;-webkit-user-select:text;user-select:text;white-space:pre}.xterm .xterm-accessibility-tree>div{transform-origin:left;width:fit-content}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;background:#0000;transition:opacity .1s linear;z-index:11}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{position:absolute;display:none}.xterm .xterm-scrollable-element>.shadow.top{display:block;top:0;left:3px;height:3px;width:100%;box-shadow:var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset}.xterm .xterm-scrollable-element>.shadow.left{display:block;top:3px;left:0;height:100%;width:3px;box-shadow:var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset}.xterm .xterm-scrollable-element>.shadow.top-left-corner{display:block;top:0;left:0;height:3px;width:3px}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset}@font-face{font-family:JetBrains Mono;src:url(/fonts/JetBrainsMono-Regular.woff2) format("woff2");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:JetBrains Mono;src:url(/fonts/JetBrainsMono-Bold.woff2) format("woff2");font-weight:700;font-style:normal;font-display:swap}@font-face{font-family:Maple Mono CN;src:url(/fonts/MapleMono-CN-Regular.woff2) format("woff2");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:Maple Mono CN;src:url(/fonts/MapleMono-CN-Bold.woff2) format("woff2");font-weight:700;font-style:normal;font-display:swap}*{margin:0;padding:0;box-sizing:border-box}html,body,#root{width:100%;height:100%;overflow:hidden;background-color:#1a1b26;font-family:Maple Mono CN,JetBrains Mono,Menlo,Monaco,Courier New,monospace;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizeLegibility}body.resizing-panes,body.resizing-panes *{cursor:col-resize!important;-webkit-user-select:none!important;user-select:none!important}.session-sidebar{transition:width .2s ease;overflow:hidden;flex-shrink:0}body.resizing-panes-v,body.resizing-panes-v *{cursor:row-resize!important;-webkit-user-select:none!important;user-select:none!important}button{transition:all .15s ease;font-family:inherit}button:hover{filter:brightness(1.2)}button:active{transform:scale(.97)}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#292e42;border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#414868}::selection{background:#7aa2f74d;color:#c0caf5}input:focus-visible,button:focus-visible{outline:1px solid #7aa2f7;outline-offset:1px}.header-btn{background:none;border:1px solid #292e42;color:#7aa2f7;padding:2px 10px;border-radius:5px;cursor:pointer;font-size:13px;line-height:1.4;transition:all .15s ease}.header-btn:hover{background:#7aa2f71a;border-color:#7aa2f7}.header-btn--muted{color:#565f89;font-size:12px}.header-btn--muted:hover{color:#a9b1d6;border-color:#565f89;background:#565f891a}.pane-btn{background:none;border:none;color:#565f89;cursor:pointer;font-size:14px;line-height:1;padding:2px 4px;border-radius:3px;transition:all .15s ease}.pane-btn:hover{color:#7aa2f7;background:#7aa2f71a}.pane-btn--danger:hover{color:#f7768e;background:#f7768e1a}.login-input{transition:border-color .2s ease,box-shadow .2s ease}.login-input:focus{border-color:#7aa2f7!important;box-shadow:0 0 0 3px #7aa2f726}.login-card{box-shadow:0 8px 32px #0006,0 0 0 1px #7aa2f714;transition:box-shadow .3s ease}.login-submit{transition:all .2s ease}.login-submit:not(:disabled):hover{filter:brightness(1.1);box-shadow:0 4px 12px #7aa2f74d}.md-editor-textarea{flex:1;min-width:0;resize:none;border:none;outline:none;padding:8px 12px;font-family:Maple Mono CN,JetBrains Mono,Menlo,Monaco,Courier New,monospace;font-size:13px;line-height:1.5;color:#a9b1d6;background-color:#1a1b26;-moz-tab-size:2;tab-size:2}.md-editor-textarea::placeholder{color:#414868;font-style:italic}.md-editor-divider{height:4px;background:#292e42;cursor:row-resize;flex-shrink:0;transition:background .15s ease}.md-editor-divider:hover{background:#7aa2f7}.pane-btn--active{color:#7aa2f7}.plan-panel-body{display:flex;flex-direction:row;flex:1;min-height:0;overflow:hidden}.plan-renderer{overflow:hidden;padding:0;min-width:0;-webkit-user-select:text;user-select:text}.plan-divider-h{width:4px;background:#292e42;cursor:col-resize;flex-shrink:0;transition:background .15s ease}.plan-divider-h:hover{background:#7aa2f7}.plan-editor-wrap{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}.plan-filename-input{background:#1a1b26;border:1px solid #292e42;color:#a9b1d6;padding:2px 6px;border-radius:3px;font-family:inherit;font-size:11px;width:120px;outline:none;transition:border-color .15s ease}.plan-filename-input:focus{border-color:#7aa2f7}body.resizing-panes-h,body.resizing-panes-h *{cursor:col-resize!important;-webkit-user-select:none!important;user-select:none!important}.md-preview{color:#a9b1d6;font-size:13px;line-height:1.6;word-wrap:break-word}.md-preview h1{color:#7aa2f7;font-size:1.4em;margin:.6em 0 .3em;padding-bottom:.2em;border-bottom:1px solid #292e42}.md-preview h2{color:#bb9af7;font-size:1.2em;margin:.5em 0 .3em}.md-preview h3{color:#7dcfff;font-size:1.05em;margin:.4em 0 .2em}.md-preview p{margin:.4em 0}.md-preview code{background:#24283b;color:#c0caf5;padding:1px 4px;border-radius:3px;font-size:.9em;font-family:Maple Mono CN,JetBrains Mono,monospace}.md-preview pre{background:#24283b;border:1px solid #292e42;border-radius:4px;padding:10px 12px;overflow-x:auto;margin:.5em 0}.md-preview pre code{background:none;padding:0;border-radius:0}.md-preview blockquote{border-left:3px solid #7aa2f7;padding:2px 12px;margin:.4em 0;color:#565f89}.md-preview ul,.md-preview ol{padding-left:1.5em;margin:.3em 0}.md-preview li{margin:.15em 0}.md-preview table{border-collapse:collapse;width:100%;margin:.5em 0;font-size:12px}.md-preview th,.md-preview td{border:1px solid #292e42;padding:4px 8px;text-align:left}.md-preview th{background:#24283b;color:#7aa2f7;font-weight:600}.md-preview a{color:#7aa2f7;text-decoration:none}.md-preview a:hover{text-decoration:underline}.md-preview hr{border:none;border-top:1px solid #292e42;margin:.6em 0}.slash-dropdown{flex-shrink:0;max-height:180px;overflow-y:auto;background:#1e2030;border-bottom:1px solid #292e42;z-index:10;font-size:12px}.slash-item{display:flex;align-items:center;gap:8px;padding:5px 10px;cursor:pointer;transition:background .1s ease}.slash-item:hover,.slash-item--active{background:#292e42}.slash-cmd{color:#7aa2f7;font-weight:600;min-width:120px;font-family:Maple Mono CN,JetBrains Mono,monospace}.slash-desc{color:#565f89;font-size:11px}.tab-bar{display:flex;align-items:center;padding:0 8px;height:28px;background:#1a1b26;border-top:1px solid #292e42;flex-shrink:0;overflow-x:auto;gap:2px}.tab-bar::-webkit-scrollbar{height:0}.tab-item{display:flex;align-items:center;gap:6px;padding:2px 10px;font-size:12px;color:#565f89;cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s ease,border-color .15s ease,background .15s ease;border-radius:4px 4px 0 0;-webkit-user-select:none;user-select:none;flex-shrink:0}.tab-item:hover{color:#a9b1d6;background:#7aa2f70d}.tab-item--active{color:#c0caf5;border-bottom-color:#7aa2f7;background:#7aa2f714}.tab-item__name{max-width:150px;overflow:hidden;text-overflow:ellipsis}.tab-item__count{font-size:10px;color:#414868}.tab-item__close{font-size:14px;line-height:1;color:inherit;opacity:0;background:none;border:none;cursor:pointer;padding:0 2px;border-radius:3px;transition:opacity .1s ease}.tab-item:hover .tab-item__close{opacity:.5}.tab-item__close:hover{opacity:1!important;color:#f7768e;background:#f7768e1a}.tab-item__rename-input{background:transparent;border:1px solid #7aa2f7;color:#c0caf5;font-size:12px;font-family:inherit;padding:0 4px;border-radius:2px;outline:none;width:100px}.tab-bar-add{background:none;border:1px solid transparent;color:#565f89;font-size:16px;line-height:1;padding:2px 8px;border-radius:4px;cursor:pointer;margin-left:4px;flex-shrink:0}.tab-bar-add:hover{color:#7aa2f7;border-color:#292e42;background:#7aa2f71a}.pdf-renderer{height:100%;overflow-y:auto;padding:12px;background:#1a1b26}.pdf-renderer canvas{display:block;margin:0 auto 8px;max-width:100%}.doc-expanded-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:100;background:#1a1b26;display:flex;flex-direction:column}.doc-expanded-header{display:flex;align-items:center;justify-content:space-between;padding:0 12px;height:32px;flex-shrink:0;background:#16161e;border-bottom:1px solid #292e42}.md-editor-actions{display:flex;align-items:center;gap:8px;padding:4px 8px;flex-shrink:0;background:#16161e;border-top:1px solid #292e42}