ai-cli-online 2.6.0 → 2.9.1

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/README.md CHANGED
@@ -20,18 +20,19 @@ Ideal for **unstable networks** where SSH drops frequently, or as a **local stat
20
20
  - **Session Persistence** — tmux keeps processes alive through disconnects; fixed socket path ensures auto-reconnect even after server restarts
21
21
  - **Multi-Tab** — independent terminal groups with layout persistence across browser refreshes
22
22
  - **Split Panes** — horizontal / vertical splits, arbitrarily nested
23
- - **Document Browser** — view Markdown, HTML, and PDF files alongside your terminal; file picker shows file sizes
24
23
  - **Mermaid Diagrams** — Gantt charts, flowcharts, and other Mermaid diagrams render inline with dark theme; CDN fallback for reliability
25
- - **Plan Annotations** — add, edit, and delete inline annotations on document content with persistent storage
24
+ - **Plan Annotations** — 4 annotation types (insert / delete / replace / comment) on document content with persistent storage; selection float button group for quick action
26
25
  - **Editor Panel** — multi-line editing with server-side draft persistence (SQLite), undo stack, and slash commands (`/history`, etc.)
27
26
  - **Copy & Paste** — mouse selection auto-copies to clipboard; right-click pastes into terminal
28
27
  - **File Transfer** — upload files to CWD, browse and download via REST API
29
28
  - **Scroll History** — capture-pane scrollback viewer with ANSI color preservation
30
29
  - **Session Management** — sidebar to restore, delete, rename sessions, and close individual terminals (with confirmation)
30
+ - **CJK Font Support** — LXGW WenKai Mono via CDN with unicode-range subsetting; browser loads only needed CJK glyphs on demand
31
31
  - **Font Size Control** — adjustable terminal font size (A−/A+) with server-side persistence
32
32
  - **Network Indicator** — real-time RTT latency display with signal bars
33
33
  - **Auto Reconnect** — exponential backoff with jitter to prevent thundering herd
34
34
  - **Secure Auth** — token authentication with timing-safe comparison
35
+ - **Security Hardening** — symlink traversal protection, unauthenticated WebSocket connection limits, TOCTOU download guard, CSP headers (frame-ancestors, base-uri, form-action)
35
36
 
36
37
  ## Comparison: AI-Cli Online vs OpenClaw
37
38
 
@@ -46,14 +47,14 @@ Ideal for **unstable networks** where SSH drops frequently, or as a **local stat
46
47
  | **Native Apps** | None (pure web) | macOS + iOS + Android |
47
48
  | **Voice Interaction** | None | Voice Wake + Talk Mode |
48
49
  | **AI Agent** | None built-in (run any CLI) | Pi Agent runtime + multi-agent routing |
49
- | **Canvas / UI** | Document browser (MD / HTML / PDF) | A2UI real-time visual workspace |
50
+ | **Canvas / UI** | Plan annotations (Markdown) | A2UI real-time visual workspace |
50
51
  | **File Transfer** | REST API upload / download | Channel-native media |
51
52
  | **Security Model** | Token auth + timing-safe | Device pairing + DM policy + Docker sandbox |
52
53
  | **Extensibility** | Shell scripts | 33 extensions + 60+ skills + ClawHub |
53
54
  | **Transport** | Binary frames (ultra-low latency) | JSON WebSocket |
54
55
  | **Deployment** | Single-node Node.js | Single-node + Tailscale Serve/Funnel |
55
56
  | **Tech Stack** | React + Express + node-pty | Lit + Express + Pi Agent |
56
- | **Package Size** | ~950 KB | ~300 MB+ |
57
+ | **Package Size** | ~1 MB | ~300 MB+ |
57
58
  | **Install** | `npx ai-cli-online` | `npm i -g openclaw && openclaw onboard` |
58
59
 
59
60
  ## Quick Start
@@ -121,8 +122,7 @@ Browser (xterm.js + WebGL) <-- WebSocket binary/JSON --> Express (node-pty) <-->
121
122
  - **WebSocket compression** — `perMessageDeflate` (level 1, threshold 128 B), 50-70% bandwidth reduction
122
123
  - **WebGL renderer** — 3-10x rendering throughput vs canvas
123
124
  - **Parallel initialization** — PTY creation, tmux config, and resize run concurrently
124
- - **PDF lazy loading** — pdfjs-dist (445 KB) only loaded when a user opens a PDF file
125
- - **Smart re-render** — matchMedia threshold hook, conditional Zustand selectors, batched stat calls
125
+ - **Smart re-render** — responsive layout hook, conditional Zustand selectors, batched stat calls
126
126
 
127
127
  ## Project Structure
128
128
 
package/README.zh-CN.md CHANGED
@@ -20,18 +20,19 @@
20
20
  - **会话持久化** — tmux 保证断网后进程存活;固定 socket 路径,服务重启后自动重连
21
21
  - **Tab 多标签页** — 独立终端分组,布局跨刷新持久化
22
22
  - **分屏布局** — 水平/垂直任意嵌套分割
23
- - **文档浏览器** — 终端旁查看 Markdown / HTML / PDF 文件
24
23
  - **Mermaid 图表** — 甘特图、流程图等 Mermaid 图表暗色主题内联渲染,CDN 双源容错
25
- - **Plan 批注** — 文档内容内联批注,支持新增/编辑/删除,持久化存储
24
+ - **Plan 批注** — 4 种批注类型(插入/删除/替换/评注),选中文本弹出浮动按钮组,持久化存储
26
25
  - **编辑器面板** — 多行编辑 + 草稿服务端持久化 (SQLite) + undo 撤销栈 + 斜杠命令(`/history` 等)
27
26
  - **复制粘贴** — 鼠标选中自动复制到剪贴板,右键粘贴到终端
28
27
  - **文件传输** — 上传文件到 CWD,浏览/下载通过 REST API
29
28
  - **滚动历史** — capture-pane 回看,保留 ANSI 颜色
30
29
  - **会话管理** — 侧边栏管理 session(恢复/删除/重命名)
30
+ - **中文等宽字体** — 通过 CDN 加载霞鹜文楷等宽 (LXGW WenKai Mono),unicode-range 按需分片,浏览器仅下载实际用到的字符
31
31
  - **字体大小控制** — 可调节终端字体大小 (A−/A+),设置服务端持久化
32
32
  - **网络指示器** — 实时 RTT 延迟显示 + 信号条
33
33
  - **自动重连** — 指数退避 + jitter 防雷群效应
34
34
  - **安全认证** — Token 认证 + timing-safe 比较
35
+ - **安全加固** — symlink 穿越防护、未认证 WebSocket 连接限制、TOCTOU 下载防护、CSP Headers (frame-ancestors / base-uri / form-action)
35
36
 
36
37
  ## 功能对比:AI-Cli Online vs OpenClaw
37
38
 
@@ -46,14 +47,14 @@
46
47
  | **原生应用** | 无(纯 Web) | macOS + iOS + Android |
47
48
  | **语音交互** | 无 | Voice Wake + Talk Mode |
48
49
  | **AI Agent** | 无内置(运行任意 CLI) | Pi Agent 运行时 + 多 Agent 路由 |
49
- | **Canvas/UI** | 文档浏览器(MD / HTML / PDF) | A2UI 实时可视化工作区 |
50
+ | **Canvas/UI** | Plan 批注系统(Markdown) | A2UI 实时可视化工作区 |
50
51
  | **文件传输** | REST API 上传/下载 | 渠道原生媒体 |
51
52
  | **安全模型** | Token auth + timing-safe | 设备配对 + DM 策略 + Docker 沙箱 |
52
53
  | **可扩展性** | Shell 脚本 | 33 扩展 + 60+ Skills + ClawHub |
53
54
  | **传输协议** | 二进制帧(超低延迟) | JSON WebSocket |
54
55
  | **部署** | 单机 Node.js | 单机 + Tailscale Serve/Funnel |
55
56
  | **技术栈** | React + Express + node-pty | Lit + Express + Pi Agent |
56
- | **包大小** | ~950 KB | ~300 MB+ |
57
+ | **包大小** | ~1 MB | ~300 MB+ |
57
58
  | **安装** | `npx ai-cli-online` | `npm i -g openclaw && openclaw onboard` |
58
59
 
59
60
  ## 快速开始
@@ -121,8 +122,7 @@ TRUST_PROXY=1 # nginx 反代时设为 1
121
122
  - **WebSocket 压缩** — `perMessageDeflate`(level 1,threshold 128 B),带宽减少 50-70%
122
123
  - **WebGL 渲染器** — 渲染吞吐量提升 3-10 倍
123
124
  - **并行初始化** — PTY 创建、tmux 配置、resize 并行执行
124
- - **PDF 懒加载** pdfjs-dist (445 KB) 仅在用户打开 PDF 文件时加载
125
- - **智能重渲染** — matchMedia 阈值 hook、条件 Zustand selector、分批 stat 调用
125
+ - **智能重渲染**响应式布局 hook、条件 Zustand selector、分批 stat 调用
126
126
 
127
127
  ## 项目结构
128
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cli-online",
3
- "version": "2.6.0",
3
+ "version": "2.9.1",
4
4
  "description": "AI-Cli Online - Web Terminal for Claude Code via xterm.js + tmux",
5
5
  "license": "MIT",
6
6
  "bin": {
package/server/dist/db.js CHANGED
@@ -35,6 +35,9 @@ db.exec(`
35
35
  PRIMARY KEY (session_name, file_path)
36
36
  )
37
37
  `);
38
+ // Indexes for periodic cleanup queries on updated_at
39
+ db.exec('CREATE INDEX IF NOT EXISTS idx_drafts_updated_at ON drafts(updated_at)');
40
+ db.exec('CREATE INDEX IF NOT EXISTS idx_annotations_updated_at ON annotations(updated_at)');
38
41
  // --- Annotations statements ---
39
42
  const stmtAnnGet = db.prepare('SELECT content, updated_at FROM annotations WHERE session_name = ? AND file_path = ?');
40
43
  const stmtAnnUpsert = db.prepare(`
@@ -17,5 +17,10 @@ export declare function listFiles(dirPath: string): Promise<ListFilesResult>;
17
17
  * We resolve the path and ensure it's an absolute path that exists.
18
18
  */
19
19
  export declare function validatePath(requested: string, baseCwd: string): Promise<string | null>;
20
+ /**
21
+ * Validate path and reject symlinks (A1).
22
+ * Used for download/file-content/stream-file to prevent symlink traversal.
23
+ */
24
+ export declare function validatePathNoSymlink(requested: string, baseCwd: string): Promise<string | null>;
20
25
  /** Validate a path that may not exist yet (for touch/mkdir). Uses realpath on baseCwd only. */
21
26
  export declare function validateNewPath(requested: string, baseCwd: string): Promise<string | null>;
@@ -1,7 +1,30 @@
1
- import { readdir, stat, realpath } from 'fs/promises';
1
+ import { readdir, stat, lstat, realpath } from 'fs/promises';
2
2
  import { join, resolve } from 'path';
3
3
  export const MAX_UPLOAD_SIZE = 100 * 1024 * 1024; // 100 MB
4
4
  export const MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024; // 100 MB
5
+ /* ── realpath cache (C3) ── */
6
+ const realpathCache = new Map();
7
+ const REALPATH_CACHE_TTL = 5000; // 5s
8
+ const REALPATH_CACHE_MAX = 100;
9
+ async function cachedRealpath(p) {
10
+ const now = Date.now();
11
+ const cached = realpathCache.get(p);
12
+ if (cached && now < cached.expiresAt)
13
+ return cached.value;
14
+ const real = await realpath(p);
15
+ if (realpathCache.size >= REALPATH_CACHE_MAX) {
16
+ // Evict oldest entry
17
+ const first = realpathCache.keys().next().value;
18
+ if (first !== undefined)
19
+ realpathCache.delete(first);
20
+ }
21
+ realpathCache.set(p, { value: real, expiresAt: now + REALPATH_CACHE_TTL });
22
+ return real;
23
+ }
24
+ /* ── Path containment helper (B3) ── */
25
+ function isContainedIn(path, base) {
26
+ return path === base || path.startsWith(base + '/');
27
+ }
5
28
  const MAX_DIR_ENTRIES = 1000;
6
29
  /** List files in a directory, directories first, then alphabetical */
7
30
  export async function listFiles(dirPath) {
@@ -48,25 +71,40 @@ export async function validatePath(requested, baseCwd) {
48
71
  try {
49
72
  const resolved = resolve(baseCwd, requested);
50
73
  const real = await realpath(resolved);
51
- // Containment check: ensure resolved path is within baseCwd
52
- const realBase = await realpath(baseCwd);
53
- if (real !== realBase && !real.startsWith(realBase + '/')) {
74
+ const realBase = await cachedRealpath(baseCwd);
75
+ if (!isContainedIn(real, realBase))
54
76
  return null;
55
- }
56
77
  return real;
57
78
  }
58
79
  catch {
59
80
  return null;
60
81
  }
61
82
  }
83
+ /**
84
+ * Validate path and reject symlinks (A1).
85
+ * Used for download/file-content/stream-file to prevent symlink traversal.
86
+ */
87
+ export async function validatePathNoSymlink(requested, baseCwd) {
88
+ const resolved = await validatePath(requested, baseCwd);
89
+ if (!resolved)
90
+ return null;
91
+ try {
92
+ const s = await lstat(resolved);
93
+ if (s.isSymbolicLink())
94
+ return null;
95
+ return resolved;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
62
101
  /** Validate a path that may not exist yet (for touch/mkdir). Uses realpath on baseCwd only. */
63
102
  export async function validateNewPath(requested, baseCwd) {
64
103
  try {
65
- const realBase = await realpath(baseCwd);
104
+ const realBase = await cachedRealpath(baseCwd);
66
105
  const resolved = resolve(realBase, requested);
67
- if (resolved !== realBase && !resolved.startsWith(realBase + '/')) {
106
+ if (!isContainedIn(resolved, realBase))
68
107
  return null;
69
- }
70
108
  return resolved;
71
109
  }
72
110
  catch {
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'url';
15
15
  import { createHash } from 'crypto';
16
16
  import { setupWebSocket, getActiveSessionNames, clearWsIntervals } from './websocket.js';
17
17
  import { isTmuxAvailable, listSessions, buildSessionName, killSession, isValidSessionId, cleanupStaleSessions, getCwd, getPaneCommand } from './tmux.js';
18
- import { listFiles, validatePath, validateNewPath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from './files.js';
18
+ import { listFiles, validatePath, validatePathNoSymlink, validateNewPath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from './files.js';
19
19
  import { getDraft, saveDraft as saveDraftDb, deleteDraft, cleanupOldDrafts, getSetting, saveSetting, getAnnotation, saveAnnotation, cleanupOldAnnotations, closeDb } from './db.js';
20
20
  import { safeTokenCompare } from './auth.js';
21
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -58,9 +58,13 @@ async function main() {
58
58
  directives: {
59
59
  defaultSrc: ["'self'"],
60
60
  scriptSrc: ["'self'", "https://cdn.jsdelivr.net", "https://unpkg.com"],
61
- styleSrc: ["'self'", "'unsafe-inline'"],
61
+ styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
62
+ fontSrc: ["'self'", "https://cdn.jsdelivr.net"],
62
63
  imgSrc: ["'self'", "https:", "data:", "blob:"],
63
64
  connectSrc: ["'self'", "wss:", "ws:"],
65
+ frameAncestors: ["'none'"],
66
+ baseUri: ["'self'"],
67
+ formAction: ["'self'"],
64
68
  },
65
69
  },
66
70
  frameguard: { action: 'deny' },
@@ -255,7 +259,7 @@ async function main() {
255
259
  res.status(400).json({ error: 'path query parameter required' });
256
260
  return;
257
261
  }
258
- const resolved = await validatePath(filePath, cwd);
262
+ const resolved = await validatePathNoSymlink(filePath, cwd);
259
263
  if (!resolved) {
260
264
  res.status(400).json({ error: 'Invalid path' });
261
265
  return;
@@ -271,7 +275,25 @@ async function main() {
271
275
  }
272
276
  res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(resolved))}"`);
273
277
  res.setHeader('Content-Length', fileStat.size);
274
- createReadStream(resolved).pipe(res);
278
+ // A3: Stream with byte counting to guard against TOCTOU size changes
279
+ const stream = createReadStream(resolved);
280
+ let bytesWritten = 0;
281
+ stream.on('data', (chunk) => {
282
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
283
+ bytesWritten += buf.length;
284
+ if (bytesWritten > MAX_DOWNLOAD_SIZE) {
285
+ stream.destroy();
286
+ if (!res.writableEnded)
287
+ res.end();
288
+ return;
289
+ }
290
+ if (!res.writableEnded)
291
+ res.write(chunk);
292
+ });
293
+ stream.on('end', () => { if (!res.writableEnded)
294
+ res.end(); });
295
+ stream.on('error', () => { if (!res.writableEnded)
296
+ res.end(); });
275
297
  }
276
298
  catch (err) {
277
299
  console.error(`[api:download] ${sessionName}:`, err);
@@ -285,6 +307,11 @@ async function main() {
285
307
  return;
286
308
  try {
287
309
  const cwd = await getCwd(sessionName);
310
+ // A5: Validate CWD format before passing to tar
311
+ if (!cwd.startsWith('/') || cwd.includes('\0')) {
312
+ res.status(400).json({ error: 'Invalid working directory' });
313
+ return;
314
+ }
288
315
  const dirName = basename(cwd);
289
316
  res.setHeader('Content-Type', 'application/gzip');
290
317
  res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`);
@@ -547,7 +574,7 @@ async function main() {
547
574
  }
548
575
  try {
549
576
  const cwd = await getCwd(sessionName);
550
- const resolved = await validatePath(filePath, cwd);
577
+ const resolved = await validatePathNoSymlink(filePath, cwd);
551
578
  if (!resolved) {
552
579
  res.status(400).json({ error: 'Invalid path' });
553
580
  return;
@@ -1,10 +1,13 @@
1
1
  import { WebSocket } from 'ws';
2
2
  import { buildSessionName, isValidSessionId, tokenToSessionName, hasSession, createSession, configureSession, captureScrollback, resizeSession, getCwd, } from './tmux.js';
3
- import { validatePath } from './files.js';
3
+ import { validatePathNoSymlink } from './files.js';
4
4
  import { createReadStream } from 'fs';
5
5
  import { stat as fsStat } from 'fs/promises';
6
6
  import { PtySession } from './pty.js';
7
7
  import { BIN_TYPE_OUTPUT, BIN_TYPE_INPUT, BIN_TYPE_SCROLLBACK, BIN_TYPE_SCROLLBACK_CONTENT, BIN_TYPE_FILE_CHUNK } from 'ai-cli-online-shared';
8
+ // A2: Limit pending (unauthenticated) WebSocket connections
9
+ const MAX_PENDING_AUTH = 50;
10
+ let pendingAuthCount = 0;
8
11
  const MAX_STREAM_SIZE = 50 * 1024 * 1024; // 50MB
9
12
  const STREAM_CHUNK_SIZE = 64 * 1024; // 64KB highWaterMark
10
13
  const STREAM_HIGH_WATER = 1024 * 1024; // 1MB backpressure threshold
@@ -111,9 +114,24 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
111
114
  socket.setNoDelay(true);
112
115
  }
113
116
  const clientIp = req.socket.remoteAddress || 'unknown';
117
+ // A2: Reject when too many unauthenticated connections are pending
118
+ let countedAsPending = false;
119
+ if (authToken) {
120
+ if (pendingAuthCount >= MAX_PENDING_AUTH) {
121
+ console.log(`[WS] Pending auth limit (${MAX_PENDING_AUTH}) reached, rejecting connection from ${clientIp}`);
122
+ ws.close(4006, 'Too many pending connections');
123
+ return;
124
+ }
125
+ pendingAuthCount++;
126
+ countedAsPending = true;
127
+ }
114
128
  // Reject connections from IPs with too many recent auth failures
115
129
  if (authToken && isAuthRateLimited(clientIp)) {
116
130
  console.log(`[WS] Auth rate-limited IP: ${clientIp}`);
131
+ if (countedAsPending) {
132
+ pendingAuthCount--;
133
+ countedAsPending = false;
134
+ }
117
135
  ws.close(4001, 'Too many auth failures');
118
136
  return;
119
137
  }
@@ -142,6 +160,10 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
142
160
  const authTimer = authToken
143
161
  ? setTimeout(() => {
144
162
  if (!authenticated) {
163
+ if (countedAsPending) {
164
+ pendingAuthCount--;
165
+ countedAsPending = false;
166
+ }
145
167
  console.log('[WS] Auth timeout — no auth message received');
146
168
  ws.close(4001, 'Auth timeout');
147
169
  }
@@ -257,9 +279,17 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
257
279
  if (!msg.token || !compareToken(msg.token, authToken)) {
258
280
  recordAuthFailure(clientIp);
259
281
  console.log(`[WS] Unauthorized — invalid token from ${clientIp}`);
282
+ if (countedAsPending) {
283
+ pendingAuthCount--;
284
+ countedAsPending = false;
285
+ }
260
286
  ws.close(4001, 'Unauthorized');
261
287
  return;
262
288
  }
289
+ if (countedAsPending) {
290
+ pendingAuthCount--;
291
+ countedAsPending = false;
292
+ }
263
293
  authenticated = true;
264
294
  authenticatedToken = msg.token;
265
295
  await initSession(msg.token);
@@ -306,7 +336,7 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
306
336
  }
307
337
  try {
308
338
  const cwd = await getCwd(sessionName);
309
- const resolved = await validatePath(msg.path, cwd);
339
+ const resolved = await validatePathNoSymlink(msg.path, cwd);
310
340
  if (!resolved) {
311
341
  send(ws, { type: 'file-stream-error', error: 'Invalid path' });
312
342
  break;
@@ -333,18 +363,18 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
333
363
  buf[0] = BIN_TYPE_FILE_CHUNK;
334
364
  buf.set(data, 1);
335
365
  ws.send(buf);
336
- // Backpressure: pause stream when WS send buffer is full
366
+ // Backpressure: pause stream when WS send buffer is full, resume on drain
337
367
  if (ws.bufferedAmount > STREAM_HIGH_WATER) {
338
368
  stream.pause();
339
- const checkDrain = () => {
369
+ const onDrain = () => {
340
370
  if (ws.bufferedAmount < STREAM_LOW_WATER) {
341
371
  stream.resume();
342
372
  }
343
373
  else {
344
- setTimeout(checkDrain, 10);
374
+ ws.once('drain', onDrain);
345
375
  }
346
376
  };
347
- setTimeout(checkDrain, 10);
377
+ ws.once('drain', onDrain);
348
378
  }
349
379
  });
350
380
  stream.on('end', () => {
@@ -375,6 +405,10 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
375
405
  }
376
406
  });
377
407
  ws.on('close', () => {
408
+ if (countedAsPending) {
409
+ pendingAuthCount--;
410
+ countedAsPending = false;
411
+ }
378
412
  if (authTimer)
379
413
  clearTimeout(authTimer);
380
414
  if (activeFileStream) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cli-online-server",
3
- "version": "2.6.0",
3
+ "version": "2.9.1",
4
4
  "description": "CLI-Online Backend Server",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cli-online-shared",
3
- "version": "2.4.0",
3
+ "version": "2.9.1",
4
4
  "description": "Shared types for CLI-Online",
5
5
  "type": "module",
6
6
  "main": "dist/types.js",