ai-cli-online 2.5.1 → 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cli-online",
3
- "version": "2.5.1",
3
+ "version": "2.6.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,23 @@ 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
+ // --- Annotations statements ---
39
+ const stmtAnnGet = db.prepare('SELECT content, updated_at FROM annotations WHERE session_name = ? AND file_path = ?');
40
+ const stmtAnnUpsert = db.prepare(`
41
+ INSERT INTO annotations (session_name, file_path, content, updated_at) VALUES (?, ?, ?, ?)
42
+ ON CONFLICT(session_name, file_path) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at
43
+ `);
44
+ const stmtAnnDelete = db.prepare('DELETE FROM annotations WHERE session_name = ? AND file_path = ?');
45
+ const stmtAnnCleanup = db.prepare('DELETE FROM annotations WHERE updated_at < ?');
29
46
  // --- Drafts statements ---
30
47
  const stmtGet = db.prepare('SELECT content FROM drafts WHERE session_name = ?');
31
48
  const stmtUpsert = db.prepare(`
@@ -68,6 +85,24 @@ export function cleanupOldDrafts(maxAgeDays = 7) {
68
85
  const result = stmtCleanup.run(cutoff);
69
86
  return result.changes;
70
87
  }
88
+ // --- Annotation functions ---
89
+ export function getAnnotation(sessionName, filePath) {
90
+ const row = stmtAnnGet.get(sessionName, filePath);
91
+ return row ? { content: row.content, updatedAt: row.updated_at } : null;
92
+ }
93
+ export function saveAnnotation(sessionName, filePath, content, updatedAt) {
94
+ if (!content || content === '{}' || content === '{"additions":[],"deletions":[]}') {
95
+ stmtAnnDelete.run(sessionName, filePath);
96
+ }
97
+ else {
98
+ stmtAnnUpsert.run(sessionName, filePath, content, updatedAt);
99
+ }
100
+ }
101
+ export function cleanupOldAnnotations(maxAgeDays = 7) {
102
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
103
+ const result = stmtAnnCleanup.run(cutoff);
104
+ return result.changes;
105
+ }
71
106
  export function closeDb() {
72
107
  db.close();
73
108
  }
@@ -17,3 +17,5 @@ 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
+ /** Validate a path that may not exist yet (for touch/mkdir). Uses realpath on baseCwd only. */
21
+ export declare function validateNewPath(requested: string, baseCwd: string): Promise<string | null>;
@@ -59,3 +59,17 @@ export async function validatePath(requested, baseCwd) {
59
59
  return null;
60
60
  }
61
61
  }
62
+ /** Validate a path that may not exist yet (for touch/mkdir). Uses realpath on baseCwd only. */
63
+ export async function validateNewPath(requested, baseCwd) {
64
+ try {
65
+ const realBase = await realpath(baseCwd);
66
+ const resolved = resolve(realBase, requested);
67
+ if (resolved !== realBase && !resolved.startsWith(realBase + '/')) {
68
+ return null;
69
+ }
70
+ return resolved;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
@@ -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, 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();
@@ -178,13 +179,24 @@ async function main() {
178
179
  try {
179
180
  const cwd = await getCwd(sessionName);
180
181
  const subPath = req.query.path || '';
181
- const targetDir = subPath ? await validatePath(subPath, cwd) : cwd;
182
+ let targetDir = null;
183
+ if (subPath) {
184
+ targetDir = await validatePath(subPath, cwd);
185
+ // Fallback: allow absolute paths under HOME (e.g., ~/.claude/commands)
186
+ if (!targetDir) {
187
+ const home = process.env.HOME || '/root';
188
+ targetDir = await validatePath(subPath, home);
189
+ }
190
+ }
191
+ else {
192
+ targetDir = cwd;
193
+ }
182
194
  if (!targetDir) {
183
195
  res.status(400).json({ error: 'Invalid path' });
184
196
  return;
185
197
  }
186
198
  const { files, truncated } = await listFiles(targetDir);
187
- res.json({ cwd: targetDir, files, truncated });
199
+ res.json({ cwd: targetDir, home: process.env.HOME || '/root', files, truncated });
188
200
  }
189
201
  catch (err) {
190
202
  console.error(`[api:files] ${sessionName}:`, err);
@@ -266,6 +278,36 @@ async function main() {
266
278
  res.status(404).json({ error: 'File not found' });
267
279
  }
268
280
  });
281
+ // Download CWD as tar.gz
282
+ app.get('/api/sessions/:sessionId/download-cwd', async (req, res) => {
283
+ const sessionName = resolveSession(req, res);
284
+ if (!sessionName)
285
+ return;
286
+ try {
287
+ const cwd = await getCwd(sessionName);
288
+ const dirName = basename(cwd);
289
+ res.setHeader('Content-Type', 'application/gzip');
290
+ res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`);
291
+ const tar = spawn('tar', ['czf', '-', '-C', cwd, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
292
+ tar.stdout.pipe(res);
293
+ tar.stderr.on('data', (data) => console.error(`[tar stderr] ${data}`));
294
+ tar.on('error', (err) => {
295
+ console.error('[api:download-cwd] tar error:', err);
296
+ if (!res.headersSent)
297
+ res.status(500).json({ error: 'Failed to create archive' });
298
+ });
299
+ tar.on('close', (code) => {
300
+ if (code !== 0 && !res.headersSent) {
301
+ res.status(500).json({ error: 'Archive creation failed' });
302
+ }
303
+ });
304
+ }
305
+ catch (err) {
306
+ console.error(`[api:download-cwd] ${sessionName}:`, err);
307
+ if (!res.headersSent)
308
+ res.status(500).json({ error: 'Failed to download' });
309
+ }
310
+ });
269
311
  // --- Draft API ---
270
312
  // Get draft for a session
271
313
  app.get('/api/sessions/:sessionId/draft', (req, res) => {
@@ -288,6 +330,37 @@ async function main() {
288
330
  saveDraftDb(sessionName, content);
289
331
  res.json({ ok: true });
290
332
  });
333
+ // --- Annotations API ---
334
+ // Get annotation for a file
335
+ app.get('/api/sessions/:sessionId/annotations', (req, res) => {
336
+ const sessionName = resolveSession(req, res);
337
+ if (!sessionName)
338
+ return;
339
+ const filePath = req.query.path;
340
+ if (!filePath) {
341
+ res.status(400).json({ error: 'path query parameter required' });
342
+ return;
343
+ }
344
+ const result = getAnnotation(sessionName, filePath);
345
+ res.json(result || { content: null, updatedAt: 0 });
346
+ });
347
+ // Save (upsert) annotation for a file
348
+ app.put('/api/sessions/:sessionId/annotations', (req, res) => {
349
+ const sessionName = resolveSession(req, res);
350
+ if (!sessionName)
351
+ return;
352
+ const { path: filePath, content, updatedAt } = req.body;
353
+ if (!filePath || typeof filePath !== 'string') {
354
+ res.status(400).json({ error: 'path must be a string' });
355
+ return;
356
+ }
357
+ if (typeof content !== 'string') {
358
+ res.status(400).json({ error: 'content must be a string' });
359
+ return;
360
+ }
361
+ saveAnnotation(sessionName, filePath, content, updatedAt || Date.now());
362
+ res.json({ ok: true });
363
+ });
291
364
  // --- Pane command API ---
292
365
  // Get current pane command (to detect if claude is running)
293
366
  app.get('/api/sessions/:sessionId/pane-command', async (req, res) => {
@@ -309,14 +382,20 @@ async function main() {
309
382
  return;
310
383
  try {
311
384
  const { name } = req.body;
312
- if (!name || typeof name !== 'string' || name.includes('/') || name.includes('..')) {
385
+ if (!name || typeof name !== 'string' || name.includes('..')) {
313
386
  res.status(400).json({ error: 'Invalid filename' });
314
387
  return;
315
388
  }
316
389
  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 });
390
+ const resolved = await validateNewPath(join(cwd, name), cwd);
391
+ if (!resolved) {
392
+ res.status(400).json({ error: 'Invalid path' });
393
+ return;
394
+ }
395
+ // Ensure parent directory exists (supports paths like "PLAN/INDEX.md")
396
+ await mkdir(dirname(resolved), { recursive: true });
397
+ await writeFile(resolved, '', { flag: 'wx' }); // create exclusively
398
+ res.json({ ok: true, path: resolved });
320
399
  }
321
400
  catch (err) {
322
401
  if (err && typeof err === 'object' && 'code' in err && err.code === 'EEXIST') {
@@ -329,6 +408,62 @@ async function main() {
329
408
  }
330
409
  }
331
410
  });
411
+ // --- Mkdir (create directory) API ---
412
+ app.post('/api/sessions/:sessionId/mkdir', async (req, res) => {
413
+ const sessionName = resolveSession(req, res);
414
+ if (!sessionName)
415
+ return;
416
+ try {
417
+ const { path: dirPath } = req.body;
418
+ if (!dirPath || typeof dirPath !== 'string' || dirPath.includes('..')) {
419
+ res.status(400).json({ error: 'Invalid path' });
420
+ return;
421
+ }
422
+ const cwd = await getCwd(sessionName);
423
+ const resolved = await validateNewPath(join(cwd, dirPath), cwd);
424
+ if (!resolved) {
425
+ res.status(400).json({ error: 'Invalid path' });
426
+ return;
427
+ }
428
+ await mkdir(resolved, { recursive: true });
429
+ res.json({ ok: true, path: resolved });
430
+ }
431
+ catch (err) {
432
+ console.error(`[api:mkdir] ${sessionName}:`, err);
433
+ res.status(500).json({ error: 'Failed to create directory' });
434
+ }
435
+ });
436
+ // --- Delete file/directory API ---
437
+ app.delete('/api/sessions/:sessionId/rm', async (req, res) => {
438
+ const sessionName = resolveSession(req, res);
439
+ if (!sessionName)
440
+ return;
441
+ try {
442
+ const { path: rmPath } = req.body;
443
+ if (!rmPath || typeof rmPath !== 'string' || rmPath.includes('..')) {
444
+ res.status(400).json({ error: 'Invalid path' });
445
+ return;
446
+ }
447
+ const cwd = await getCwd(sessionName);
448
+ const resolved = await validatePath(rmPath, cwd);
449
+ if (!resolved) {
450
+ res.status(400).json({ error: 'Invalid path' });
451
+ return;
452
+ }
453
+ const fileStat = await stat(resolved);
454
+ if (fileStat.isDirectory()) {
455
+ await rm(resolved, { recursive: true });
456
+ }
457
+ else {
458
+ await unlink(resolved);
459
+ }
460
+ res.json({ ok: true });
461
+ }
462
+ catch (err) {
463
+ console.error(`[api:rm] ${sessionName}:`, err);
464
+ res.status(500).json({ error: 'Failed to delete' });
465
+ }
466
+ });
332
467
  // --- Settings API ---
333
468
  /** Hash token for settings storage (same prefix as tmux session names) */
334
469
  function tokenHash(token) {
@@ -530,6 +665,12 @@ async function main() {
530
665
  catch (e) {
531
666
  console.error('[cleanup:drafts]', e);
532
667
  }
668
+ try {
669
+ cleanupOldAnnotations(7);
670
+ }
671
+ catch (e) {
672
+ console.error('[cleanup:annotations]', e);
673
+ }
533
674
  }, CLEANUP_INTERVAL);
534
675
  console.log(`Session TTL: ${SESSION_TTL_HOURS}h (cleanup every hour)`);
535
676
  }
@@ -4,16 +4,7 @@ import { validatePath } 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
- /**
8
- * Binary protocol for hot-path messages (output/input/scrollback).
9
- * Format: [1-byte type prefix][raw UTF-8 payload]
10
- * JSON is kept for low-frequency control messages.
11
- */
12
- const BIN_TYPE_OUTPUT = 0x01;
13
- const BIN_TYPE_INPUT = 0x02;
14
- const BIN_TYPE_SCROLLBACK = 0x03;
15
- const BIN_TYPE_SCROLLBACK_CONTENT = 0x04;
16
- const BIN_TYPE_FILE_CHUNK = 0x05;
7
+ import { BIN_TYPE_OUTPUT, BIN_TYPE_INPUT, BIN_TYPE_SCROLLBACK, BIN_TYPE_SCROLLBACK_CONTENT, BIN_TYPE_FILE_CHUNK } from 'ai-cli-online-shared';
17
8
  const MAX_STREAM_SIZE = 50 * 1024 * 1024; // 50MB
18
9
  const STREAM_CHUNK_SIZE = 64 * 1024; // 64KB highWaterMark
19
10
  const STREAM_HIGH_WATER = 1024 * 1024; // 1MB backpressure threshold
@@ -131,6 +122,7 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
131
122
  const rows = 24;
132
123
  const rawSessionId = url.searchParams.get('sessionId') || undefined;
133
124
  const sessionId = rawSessionId && isValidSessionId(rawSessionId) ? rawSessionId : undefined;
125
+ const clientCwd = url.searchParams.get('cwd') || undefined;
134
126
  if (rawSessionId && !sessionId) {
135
127
  console.log(`[WS] Invalid sessionId rejected: ${rawSessionId}`);
136
128
  ws.close(4004, 'Invalid sessionId');
@@ -179,7 +171,7 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
179
171
  // Check or create tmux session
180
172
  const resumed = await hasSession(sessionName);
181
173
  if (!resumed) {
182
- await createSession(sessionName, cols, rows, defaultCwd);
174
+ await createSession(sessionName, cols, rows, clientCwd || defaultCwd);
183
175
  }
184
176
  else {
185
177
  // resizeSession, captureScrollback, and configureSession are independent — run in parallel
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cli-online-server",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "CLI-Online Backend Server",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Binary protocol type prefixes for hot-path WebSocket messages.
3
+ * Format: [1-byte type prefix][raw UTF-8 payload]
4
+ */
5
+ export declare const BIN_TYPE_OUTPUT = 1;
6
+ export declare const BIN_TYPE_INPUT = 2;
7
+ export declare const BIN_TYPE_SCROLLBACK = 3;
8
+ export declare const BIN_TYPE_SCROLLBACK_CONTENT = 4;
9
+ export declare const BIN_TYPE_FILE_CHUNK = 5;
1
10
  export interface FileEntry {
2
11
  name: string;
3
12
  type: 'file' | 'directory';
@@ -1 +1,9 @@
1
- export {};
1
+ /**
2
+ * Binary protocol type prefixes for hot-path WebSocket messages.
3
+ * Format: [1-byte type prefix][raw UTF-8 payload]
4
+ */
5
+ export const BIN_TYPE_OUTPUT = 0x01;
6
+ export const BIN_TYPE_INPUT = 0x02;
7
+ export const BIN_TYPE_SCROLLBACK = 0x03;
8
+ export const BIN_TYPE_SCROLLBACK_CONTENT = 0x04;
9
+ export const BIN_TYPE_FILE_CHUNK = 0x05;