ai-cli-online 2.5.1 → 2.9.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.
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.5.1",
3
+ "version": "2.9.0",
4
4
  "description": "AI-Cli Online - Web Terminal for Claude Code via xterm.js + tmux",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -4,4 +4,10 @@ export declare function getDraft(sessionName: string): string;
4
4
  export declare function saveDraft(sessionName: string, content: string): void;
5
5
  export declare function deleteDraft(sessionName: string): void;
6
6
  export declare function cleanupOldDrafts(maxAgeDays?: number): number;
7
+ export declare function getAnnotation(sessionName: string, filePath: string): {
8
+ content: string;
9
+ updatedAt: number;
10
+ } | null;
11
+ export declare function saveAnnotation(sessionName: string, filePath: string, content: string, updatedAt: number): void;
12
+ export declare function cleanupOldAnnotations(maxAgeDays?: number): number;
7
13
  export declare function closeDb(): void;
package/server/dist/db.js CHANGED
@@ -26,6 +26,26 @@ db.exec(`
26
26
  PRIMARY KEY (token_hash, key)
27
27
  )
28
28
  `);
29
+ db.exec(`
30
+ CREATE TABLE IF NOT EXISTS annotations (
31
+ session_name TEXT NOT NULL,
32
+ file_path TEXT NOT NULL,
33
+ content TEXT NOT NULL DEFAULT '{}',
34
+ updated_at INTEGER NOT NULL,
35
+ PRIMARY KEY (session_name, file_path)
36
+ )
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)');
41
+ // --- Annotations statements ---
42
+ const stmtAnnGet = db.prepare('SELECT content, updated_at FROM annotations WHERE session_name = ? AND file_path = ?');
43
+ const stmtAnnUpsert = db.prepare(`
44
+ INSERT INTO annotations (session_name, file_path, content, updated_at) VALUES (?, ?, ?, ?)
45
+ ON CONFLICT(session_name, file_path) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at
46
+ `);
47
+ const stmtAnnDelete = db.prepare('DELETE FROM annotations WHERE session_name = ? AND file_path = ?');
48
+ const stmtAnnCleanup = db.prepare('DELETE FROM annotations WHERE updated_at < ?');
29
49
  // --- Drafts statements ---
30
50
  const stmtGet = db.prepare('SELECT content FROM drafts WHERE session_name = ?');
31
51
  const stmtUpsert = db.prepare(`
@@ -68,6 +88,24 @@ export function cleanupOldDrafts(maxAgeDays = 7) {
68
88
  const result = stmtCleanup.run(cutoff);
69
89
  return result.changes;
70
90
  }
91
+ // --- Annotation functions ---
92
+ export function getAnnotation(sessionName, filePath) {
93
+ const row = stmtAnnGet.get(sessionName, filePath);
94
+ return row ? { content: row.content, updatedAt: row.updated_at } : null;
95
+ }
96
+ export function saveAnnotation(sessionName, filePath, content, updatedAt) {
97
+ if (!content || content === '{}' || content === '{"additions":[],"deletions":[]}') {
98
+ stmtAnnDelete.run(sessionName, filePath);
99
+ }
100
+ else {
101
+ stmtAnnUpsert.run(sessionName, filePath, content, updatedAt);
102
+ }
103
+ }
104
+ export function cleanupOldAnnotations(maxAgeDays = 7) {
105
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
106
+ const result = stmtAnnCleanup.run(cutoff);
107
+ return result.changes;
108
+ }
71
109
  export function closeDb() {
72
110
  db.close();
73
111
  }
@@ -17,3 +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>;
25
+ /** Validate a path that may not exist yet (for touch/mkdir). Uses realpath on baseCwd only. */
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,14 +71,43 @@ 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
+ }
101
+ /** Validate a path that may not exist yet (for touch/mkdir). Uses realpath on baseCwd only. */
102
+ export async function validateNewPath(requested, baseCwd) {
103
+ try {
104
+ const realBase = await cachedRealpath(baseCwd);
105
+ const resolved = resolve(realBase, requested);
106
+ if (!isContainedIn(resolved, realBase))
107
+ return null;
108
+ return resolved;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
@@ -8,14 +8,15 @@ import rateLimit from 'express-rate-limit';
8
8
  import multer from 'multer';
9
9
  import { config } from 'dotenv';
10
10
  import { existsSync, readFileSync, createReadStream } from 'fs';
11
- import { copyFile, unlink, stat, mkdir, readFile, writeFile } from 'fs/promises';
11
+ import { spawn } from 'child_process';
12
+ import { copyFile, unlink, stat, mkdir, readFile, writeFile, rm } from 'fs/promises';
12
13
  import { join, dirname, basename, extname } from 'path';
13
14
  import { fileURLToPath } from 'url';
14
15
  import { createHash } from 'crypto';
15
16
  import { setupWebSocket, getActiveSessionNames, clearWsIntervals } from './websocket.js';
16
17
  import { isTmuxAvailable, listSessions, buildSessionName, killSession, isValidSessionId, cleanupStaleSessions, getCwd, getPaneCommand } from './tmux.js';
17
- import { listFiles, validatePath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from './files.js';
18
- import { getDraft, saveDraft as saveDraftDb, deleteDraft, cleanupOldDrafts, getSetting, saveSetting, closeDb } from './db.js';
18
+ import { listFiles, validatePath, validatePathNoSymlink, validateNewPath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from './files.js';
19
+ import { getDraft, saveDraft as saveDraftDb, deleteDraft, cleanupOldDrafts, getSetting, saveSetting, getAnnotation, saveAnnotation, cleanupOldAnnotations, closeDb } from './db.js';
19
20
  import { safeTokenCompare } from './auth.js';
20
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
22
  config();
@@ -57,9 +58,13 @@ async function main() {
57
58
  directives: {
58
59
  defaultSrc: ["'self'"],
59
60
  scriptSrc: ["'self'", "https://cdn.jsdelivr.net", "https://unpkg.com"],
60
- styleSrc: ["'self'", "'unsafe-inline'"],
61
+ styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
62
+ fontSrc: ["'self'", "https://cdn.jsdelivr.net"],
61
63
  imgSrc: ["'self'", "https:", "data:", "blob:"],
62
64
  connectSrc: ["'self'", "wss:", "ws:"],
65
+ frameAncestors: ["'none'"],
66
+ baseUri: ["'self'"],
67
+ formAction: ["'self'"],
63
68
  },
64
69
  },
65
70
  frameguard: { action: 'deny' },
@@ -178,13 +183,24 @@ async function main() {
178
183
  try {
179
184
  const cwd = await getCwd(sessionName);
180
185
  const subPath = req.query.path || '';
181
- const targetDir = subPath ? await validatePath(subPath, cwd) : cwd;
186
+ let targetDir = null;
187
+ if (subPath) {
188
+ targetDir = await validatePath(subPath, cwd);
189
+ // Fallback: allow absolute paths under HOME (e.g., ~/.claude/commands)
190
+ if (!targetDir) {
191
+ const home = process.env.HOME || '/root';
192
+ targetDir = await validatePath(subPath, home);
193
+ }
194
+ }
195
+ else {
196
+ targetDir = cwd;
197
+ }
182
198
  if (!targetDir) {
183
199
  res.status(400).json({ error: 'Invalid path' });
184
200
  return;
185
201
  }
186
202
  const { files, truncated } = await listFiles(targetDir);
187
- res.json({ cwd: targetDir, files, truncated });
203
+ res.json({ cwd: targetDir, home: process.env.HOME || '/root', files, truncated });
188
204
  }
189
205
  catch (err) {
190
206
  console.error(`[api:files] ${sessionName}:`, err);
@@ -243,7 +259,7 @@ async function main() {
243
259
  res.status(400).json({ error: 'path query parameter required' });
244
260
  return;
245
261
  }
246
- const resolved = await validatePath(filePath, cwd);
262
+ const resolved = await validatePathNoSymlink(filePath, cwd);
247
263
  if (!resolved) {
248
264
  res.status(400).json({ error: 'Invalid path' });
249
265
  return;
@@ -259,13 +275,66 @@ async function main() {
259
275
  }
260
276
  res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(resolved))}"`);
261
277
  res.setHeader('Content-Length', fileStat.size);
262
- 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(); });
263
297
  }
264
298
  catch (err) {
265
299
  console.error(`[api:download] ${sessionName}:`, err);
266
300
  res.status(404).json({ error: 'File not found' });
267
301
  }
268
302
  });
303
+ // Download CWD as tar.gz
304
+ app.get('/api/sessions/:sessionId/download-cwd', async (req, res) => {
305
+ const sessionName = resolveSession(req, res);
306
+ if (!sessionName)
307
+ return;
308
+ try {
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
+ }
315
+ const dirName = basename(cwd);
316
+ res.setHeader('Content-Type', 'application/gzip');
317
+ res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`);
318
+ const tar = spawn('tar', ['czf', '-', '-C', cwd, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
319
+ tar.stdout.pipe(res);
320
+ tar.stderr.on('data', (data) => console.error(`[tar stderr] ${data}`));
321
+ tar.on('error', (err) => {
322
+ console.error('[api:download-cwd] tar error:', err);
323
+ if (!res.headersSent)
324
+ res.status(500).json({ error: 'Failed to create archive' });
325
+ });
326
+ tar.on('close', (code) => {
327
+ if (code !== 0 && !res.headersSent) {
328
+ res.status(500).json({ error: 'Archive creation failed' });
329
+ }
330
+ });
331
+ }
332
+ catch (err) {
333
+ console.error(`[api:download-cwd] ${sessionName}:`, err);
334
+ if (!res.headersSent)
335
+ res.status(500).json({ error: 'Failed to download' });
336
+ }
337
+ });
269
338
  // --- Draft API ---
270
339
  // Get draft for a session
271
340
  app.get('/api/sessions/:sessionId/draft', (req, res) => {
@@ -288,6 +357,37 @@ async function main() {
288
357
  saveDraftDb(sessionName, content);
289
358
  res.json({ ok: true });
290
359
  });
360
+ // --- Annotations API ---
361
+ // Get annotation for a file
362
+ app.get('/api/sessions/:sessionId/annotations', (req, res) => {
363
+ const sessionName = resolveSession(req, res);
364
+ if (!sessionName)
365
+ return;
366
+ const filePath = req.query.path;
367
+ if (!filePath) {
368
+ res.status(400).json({ error: 'path query parameter required' });
369
+ return;
370
+ }
371
+ const result = getAnnotation(sessionName, filePath);
372
+ res.json(result || { content: null, updatedAt: 0 });
373
+ });
374
+ // Save (upsert) annotation for a file
375
+ app.put('/api/sessions/:sessionId/annotations', (req, res) => {
376
+ const sessionName = resolveSession(req, res);
377
+ if (!sessionName)
378
+ return;
379
+ const { path: filePath, content, updatedAt } = req.body;
380
+ if (!filePath || typeof filePath !== 'string') {
381
+ res.status(400).json({ error: 'path must be a string' });
382
+ return;
383
+ }
384
+ if (typeof content !== 'string') {
385
+ res.status(400).json({ error: 'content must be a string' });
386
+ return;
387
+ }
388
+ saveAnnotation(sessionName, filePath, content, updatedAt || Date.now());
389
+ res.json({ ok: true });
390
+ });
291
391
  // --- Pane command API ---
292
392
  // Get current pane command (to detect if claude is running)
293
393
  app.get('/api/sessions/:sessionId/pane-command', async (req, res) => {
@@ -309,14 +409,20 @@ async function main() {
309
409
  return;
310
410
  try {
311
411
  const { name } = req.body;
312
- if (!name || typeof name !== 'string' || name.includes('/') || name.includes('..')) {
412
+ if (!name || typeof name !== 'string' || name.includes('..')) {
313
413
  res.status(400).json({ error: 'Invalid filename' });
314
414
  return;
315
415
  }
316
416
  const cwd = await getCwd(sessionName);
317
- const fullPath = join(cwd, name);
318
- await writeFile(fullPath, '', { flag: 'wx' }); // create exclusively
319
- res.json({ ok: true, path: fullPath });
417
+ const resolved = await validateNewPath(join(cwd, name), cwd);
418
+ if (!resolved) {
419
+ res.status(400).json({ error: 'Invalid path' });
420
+ return;
421
+ }
422
+ // Ensure parent directory exists (supports paths like "PLAN/INDEX.md")
423
+ await mkdir(dirname(resolved), { recursive: true });
424
+ await writeFile(resolved, '', { flag: 'wx' }); // create exclusively
425
+ res.json({ ok: true, path: resolved });
320
426
  }
321
427
  catch (err) {
322
428
  if (err && typeof err === 'object' && 'code' in err && err.code === 'EEXIST') {
@@ -329,6 +435,62 @@ async function main() {
329
435
  }
330
436
  }
331
437
  });
438
+ // --- Mkdir (create directory) API ---
439
+ app.post('/api/sessions/:sessionId/mkdir', async (req, res) => {
440
+ const sessionName = resolveSession(req, res);
441
+ if (!sessionName)
442
+ return;
443
+ try {
444
+ const { path: dirPath } = req.body;
445
+ if (!dirPath || typeof dirPath !== 'string' || dirPath.includes('..')) {
446
+ res.status(400).json({ error: 'Invalid path' });
447
+ return;
448
+ }
449
+ const cwd = await getCwd(sessionName);
450
+ const resolved = await validateNewPath(join(cwd, dirPath), cwd);
451
+ if (!resolved) {
452
+ res.status(400).json({ error: 'Invalid path' });
453
+ return;
454
+ }
455
+ await mkdir(resolved, { recursive: true });
456
+ res.json({ ok: true, path: resolved });
457
+ }
458
+ catch (err) {
459
+ console.error(`[api:mkdir] ${sessionName}:`, err);
460
+ res.status(500).json({ error: 'Failed to create directory' });
461
+ }
462
+ });
463
+ // --- Delete file/directory API ---
464
+ app.delete('/api/sessions/:sessionId/rm', async (req, res) => {
465
+ const sessionName = resolveSession(req, res);
466
+ if (!sessionName)
467
+ return;
468
+ try {
469
+ const { path: rmPath } = req.body;
470
+ if (!rmPath || typeof rmPath !== 'string' || rmPath.includes('..')) {
471
+ res.status(400).json({ error: 'Invalid path' });
472
+ return;
473
+ }
474
+ const cwd = await getCwd(sessionName);
475
+ const resolved = await validatePath(rmPath, cwd);
476
+ if (!resolved) {
477
+ res.status(400).json({ error: 'Invalid path' });
478
+ return;
479
+ }
480
+ const fileStat = await stat(resolved);
481
+ if (fileStat.isDirectory()) {
482
+ await rm(resolved, { recursive: true });
483
+ }
484
+ else {
485
+ await unlink(resolved);
486
+ }
487
+ res.json({ ok: true });
488
+ }
489
+ catch (err) {
490
+ console.error(`[api:rm] ${sessionName}:`, err);
491
+ res.status(500).json({ error: 'Failed to delete' });
492
+ }
493
+ });
332
494
  // --- Settings API ---
333
495
  /** Hash token for settings storage (same prefix as tmux session names) */
334
496
  function tokenHash(token) {
@@ -412,7 +574,7 @@ async function main() {
412
574
  }
413
575
  try {
414
576
  const cwd = await getCwd(sessionName);
415
- const resolved = await validatePath(filePath, cwd);
577
+ const resolved = await validatePathNoSymlink(filePath, cwd);
416
578
  if (!resolved) {
417
579
  res.status(400).json({ error: 'Invalid path' });
418
580
  return;
@@ -530,6 +692,12 @@ async function main() {
530
692
  catch (e) {
531
693
  console.error('[cleanup:drafts]', e);
532
694
  }
695
+ try {
696
+ cleanupOldAnnotations(7);
697
+ }
698
+ catch (e) {
699
+ console.error('[cleanup:annotations]', e);
700
+ }
533
701
  }, CLEANUP_INTERVAL);
534
702
  console.log(`Session TTL: ${SESSION_TTL_HOURS}h (cleanup every hour)`);
535
703
  }