claude-remote-cli 2.15.5 → 2.15.6

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.
@@ -11,7 +11,7 @@ import cookieParser from 'cookie-parser';
11
11
  import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, deleteMeta, ensureMetaDir } from './config.js';
12
12
  import * as auth from './auth.js';
13
13
  import * as sessions from './sessions.js';
14
- import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS } from './sessions.js';
14
+ import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames } from './sessions.js';
15
15
  import { setupWebSocket } from './ws.js';
16
16
  import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
17
17
  import { isInstalled as serviceIsInstalled } from './service.js';
@@ -223,6 +223,12 @@ async function main() {
223
223
  watcher.rebuild(config.rootDirs || []);
224
224
  const server = http.createServer(app);
225
225
  const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher);
226
+ // Restore sessions from a previous update restart
227
+ const configDir = path.dirname(CONFIG_PATH);
228
+ const restoredCount = await restoreFromDisk(configDir);
229
+ if (restoredCount > 0) {
230
+ console.log(`Restored ${restoredCount} session(s) from previous update.`);
231
+ }
226
232
  // Push notifications on session idle
227
233
  sessions.onIdleChange((sessionId, idle) => {
228
234
  if (idle) {
@@ -955,6 +961,11 @@ async function main() {
955
961
  try {
956
962
  await execFileAsync('npm', ['install', '-g', 'claude-remote-cli@latest']);
957
963
  const restarting = serviceIsInstalled();
964
+ if (restarting) {
965
+ // Persist sessions so they can be restored after restart
966
+ const configDir = path.dirname(CONFIG_PATH);
967
+ serializeAll(configDir);
968
+ }
958
969
  res.json({ ok: true, restarting });
959
970
  if (restarting) {
960
971
  setTimeout(() => process.exit(0), 1000);
@@ -965,15 +976,16 @@ async function main() {
965
976
  res.status(500).json({ ok: false, error: message });
966
977
  }
967
978
  });
968
- // Clean up orphaned tmux sessions from previous runs
979
+ // Clean up orphaned tmux sessions from previous runs (skip any adopted by restore)
969
980
  try {
981
+ const adoptedNames = activeTmuxSessionNames();
970
982
  const { stdout } = await execFileAsync('tmux', ['list-sessions', '-F', '#{session_name}']);
971
- const crcSessions = stdout.trim().split('\n').filter(name => name.startsWith('crc-'));
972
- for (const name of crcSessions) {
983
+ const orphanedSessions = stdout.trim().split('\n').filter(name => name.startsWith('crc-') && !adoptedNames.has(name));
984
+ for (const name of orphanedSessions) {
973
985
  execFileAsync('tmux', ['kill-session', '-t', name]).catch(() => { });
974
986
  }
975
- if (crcSessions.length > 0) {
976
- console.log(`Cleaned up ${crcSessions.length} orphaned tmux session(s).`);
987
+ if (orphanedSessions.length > 0) {
988
+ console.log(`Cleaned up ${orphanedSessions.length} orphaned tmux session(s).`);
977
989
  }
978
990
  }
979
991
  catch {
@@ -4,7 +4,9 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
7
8
  import { readMeta, writeMeta } from './config.js';
9
+ const execFileAsync = promisify(execFile);
8
10
  const AGENT_COMMANDS = {
9
11
  claude: 'claude',
10
12
  codex: 'codex',
@@ -17,6 +19,7 @@ const AGENT_YOLO_ARGS = {
17
19
  claude: ['--dangerously-skip-permissions'],
18
20
  codex: ['--full-auto'],
19
21
  };
22
+ const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
20
23
  function generateTmuxSessionName(displayName, id) {
21
24
  const sanitized = displayName.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').slice(0, 30);
22
25
  return `crc-${sanitized}-${id.slice(0, 8)}`;
@@ -42,8 +45,8 @@ const idleChangeCallbacks = [];
42
45
  function onIdleChange(cb) {
43
46
  idleChangeCallbacks.push(cb);
44
47
  }
45
- function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux }) {
46
- const id = crypto.randomBytes(8).toString('hex');
48
+ function create({ id: providedId, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, initialScrollback }) {
49
+ const id = providedId || crypto.randomBytes(8).toString('hex');
47
50
  const createdAt = new Date().toISOString();
48
51
  const resolvedCommand = command || AGENT_COMMANDS[agent];
49
52
  // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
@@ -66,9 +69,10 @@ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktre
66
69
  env,
67
70
  });
68
71
  // Scrollback buffer: stores all PTY output so we can replay on WebSocket (re)connect
69
- const scrollback = [];
70
- let scrollbackBytes = 0;
72
+ const scrollback = initialScrollback ? [...initialScrollback] : [];
73
+ let scrollbackBytes = initialScrollback ? initialScrollback.reduce((sum, s) => sum + s.length, 0) : 0;
71
74
  const MAX_SCROLLBACK = 256 * 1024; // 256KB max
75
+ const resolvedCwd = cwd || repoPath;
72
76
  const session = {
73
77
  id,
74
78
  type: type || 'worktree',
@@ -84,6 +88,8 @@ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktre
84
88
  lastActivity: createdAt,
85
89
  scrollback,
86
90
  idle: false,
91
+ cwd: resolvedCwd,
92
+ customCommand: command || null,
87
93
  useTmux,
88
94
  tmuxSessionName,
89
95
  onPtyReplacedCallbacks: [],
@@ -194,14 +200,14 @@ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktre
194
200
  });
195
201
  }
196
202
  attachHandlers(ptyProcess, continueArgs.some(a => args.includes(a)));
197
- return { id, type: session.type, agent: session.agent, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, branchName: session.branchName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false, useTmux, tmuxSessionName };
203
+ return { id, type: session.type, agent: session.agent, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, branchName: session.branchName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false, cwd: resolvedCwd, customCommand: command || null, useTmux, tmuxSessionName };
198
204
  }
199
205
  function get(id) {
200
206
  return sessions.get(id);
201
207
  }
202
208
  function list() {
203
209
  return Array.from(sessions.values())
204
- .map(({ id, type, agent, root, repoName, repoPath, worktreeName, branchName, displayName, createdAt, lastActivity, idle, useTmux, tmuxSessionName }) => ({
210
+ .map(({ id, type, agent, root, repoName, repoPath, worktreeName, branchName, displayName, createdAt, lastActivity, idle, cwd, customCommand, useTmux, tmuxSessionName }) => ({
205
211
  id,
206
212
  type,
207
213
  agent,
@@ -214,6 +220,8 @@ function list() {
214
220
  createdAt,
215
221
  lastActivity,
216
222
  idle,
223
+ cwd,
224
+ customCommand,
217
225
  useTmux,
218
226
  tmuxSessionName,
219
227
  }))
@@ -264,4 +272,150 @@ function findRepoSession(repoPath) {
264
272
  function nextTerminalName() {
265
273
  return `Terminal ${++terminalCounter}`;
266
274
  }
267
- export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, findRepoSession, nextTerminalName, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, resolveTmuxSpawn, generateTmuxSessionName };
275
+ function serializeAll(configDir) {
276
+ const scrollbackDirPath = path.join(configDir, 'scrollback');
277
+ fs.mkdirSync(scrollbackDirPath, { recursive: true });
278
+ const serialized = [];
279
+ for (const session of sessions.values()) {
280
+ // Write scrollback to disk
281
+ const scrollbackPath = path.join(scrollbackDirPath, session.id + '.buf');
282
+ fs.writeFileSync(scrollbackPath, session.scrollback.join(''), 'utf-8');
283
+ serialized.push({
284
+ id: session.id,
285
+ type: session.type,
286
+ agent: session.agent,
287
+ root: session.root,
288
+ repoName: session.repoName,
289
+ repoPath: session.repoPath,
290
+ worktreeName: session.worktreeName,
291
+ branchName: session.branchName,
292
+ displayName: session.displayName,
293
+ createdAt: session.createdAt,
294
+ lastActivity: session.lastActivity,
295
+ useTmux: session.useTmux,
296
+ tmuxSessionName: session.tmuxSessionName,
297
+ customCommand: session.customCommand,
298
+ cwd: session.cwd,
299
+ });
300
+ }
301
+ const pending = {
302
+ version: 1,
303
+ timestamp: new Date().toISOString(),
304
+ sessions: serialized,
305
+ };
306
+ fs.writeFileSync(path.join(configDir, 'pending-sessions.json'), JSON.stringify(pending, null, 2), 'utf-8');
307
+ }
308
+ async function restoreFromDisk(configDir) {
309
+ const pendingPath = path.join(configDir, 'pending-sessions.json');
310
+ if (!fs.existsSync(pendingPath))
311
+ return 0;
312
+ let pending;
313
+ try {
314
+ pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
315
+ }
316
+ catch {
317
+ fs.unlinkSync(pendingPath);
318
+ return 0;
319
+ }
320
+ // Ignore stale files (>5 minutes old)
321
+ if (Date.now() - new Date(pending.timestamp).getTime() > STALE_THRESHOLD_MS) {
322
+ fs.unlinkSync(pendingPath);
323
+ return 0;
324
+ }
325
+ const scrollbackDirPath = path.join(configDir, 'scrollback');
326
+ let restored = 0;
327
+ for (const s of pending.sessions) {
328
+ // Load scrollback from disk
329
+ let initialScrollback;
330
+ const scrollbackPath = path.join(scrollbackDirPath, s.id + '.buf');
331
+ try {
332
+ const data = fs.readFileSync(scrollbackPath, 'utf-8');
333
+ if (data.length > 0)
334
+ initialScrollback = [data];
335
+ }
336
+ catch {
337
+ // Missing scrollback is non-fatal
338
+ }
339
+ // Determine spawn command and args
340
+ let command;
341
+ let args = [];
342
+ if (s.customCommand) {
343
+ // Terminal session — respawn the shell
344
+ command = s.customCommand;
345
+ }
346
+ else if (s.useTmux && s.tmuxSessionName) {
347
+ // Tmux session — check if tmux session is still alive
348
+ let tmuxAlive = false;
349
+ try {
350
+ await execFileAsync('tmux', ['has-session', '-t', s.tmuxSessionName]);
351
+ tmuxAlive = true;
352
+ }
353
+ catch {
354
+ // tmux session is gone
355
+ }
356
+ if (tmuxAlive) {
357
+ // Attach to surviving tmux session
358
+ command = 'tmux';
359
+ args = ['attach-session', '-t', s.tmuxSessionName];
360
+ }
361
+ else {
362
+ // Tmux session died — fall back to agent with continue args
363
+ args = [...AGENT_CONTINUE_ARGS[s.agent]];
364
+ }
365
+ }
366
+ else {
367
+ // Non-tmux agent session — respawn with continue args
368
+ args = [...AGENT_CONTINUE_ARGS[s.agent]];
369
+ }
370
+ try {
371
+ const createParams = {
372
+ id: s.id,
373
+ type: s.type,
374
+ agent: s.agent,
375
+ repoName: s.repoName,
376
+ repoPath: s.repoPath,
377
+ cwd: s.cwd,
378
+ root: s.root,
379
+ worktreeName: s.worktreeName,
380
+ branchName: s.branchName,
381
+ displayName: s.displayName,
382
+ args,
383
+ useTmux: false, // Don't re-wrap in tmux — either attaching to existing or using plain agent
384
+ };
385
+ if (command)
386
+ createParams.command = command;
387
+ if (initialScrollback)
388
+ createParams.initialScrollback = initialScrollback;
389
+ create(createParams);
390
+ restored++;
391
+ }
392
+ catch {
393
+ console.error(`Failed to restore session ${s.id} (${s.displayName})`);
394
+ }
395
+ // Clean up scrollback file
396
+ try {
397
+ fs.unlinkSync(scrollbackPath);
398
+ }
399
+ catch { /* ignore */ }
400
+ }
401
+ // Clean up
402
+ try {
403
+ fs.unlinkSync(pendingPath);
404
+ }
405
+ catch { /* ignore */ }
406
+ try {
407
+ fs.rmdirSync(path.join(configDir, 'scrollback'));
408
+ }
409
+ catch { /* ignore — may not be empty */ }
410
+ return restored;
411
+ }
412
+ /** Returns the set of tmux session names currently owned by restored sessions */
413
+ function activeTmuxSessionNames() {
414
+ const names = new Set();
415
+ for (const session of sessions.values()) {
416
+ if (session.tmuxSessionName)
417
+ names.add(session.tmuxSessionName);
418
+ }
419
+ return names;
420
+ }
421
+ export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, resolveTmuxSpawn, generateTmuxSessionName };
@@ -1,7 +1,10 @@
1
1
  import { describe, it, afterEach } from 'node:test';
2
2
  import assert from 'node:assert';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
3
6
  import * as sessions from '../server/sessions.js';
4
- import { resolveTmuxSpawn, generateTmuxSessionName } from '../server/sessions.js';
7
+ import { resolveTmuxSpawn, generateTmuxSessionName, serializeAll, restoreFromDisk } from '../server/sessions.js';
5
8
  // Track created session IDs so we can clean up after each test
6
9
  const createdIds = [];
7
10
  afterEach(() => {
@@ -425,4 +428,153 @@ describe('sessions', () => {
425
428
  done();
426
429
  });
427
430
  });
431
+ it('create accepts a predetermined id', () => {
432
+ const result = sessions.create({
433
+ id: 'custom-id-12345678',
434
+ repoName: 'test-repo',
435
+ repoPath: '/tmp',
436
+ command: '/bin/echo',
437
+ args: ['hello'],
438
+ });
439
+ createdIds.push(result.id);
440
+ assert.strictEqual(result.id, 'custom-id-12345678');
441
+ const session = sessions.get('custom-id-12345678');
442
+ assert.ok(session);
443
+ });
444
+ it('create accepts initialScrollback', () => {
445
+ const result = sessions.create({
446
+ repoName: 'test-repo',
447
+ repoPath: '/tmp',
448
+ command: '/bin/echo',
449
+ args: ['hello'],
450
+ initialScrollback: ['prior output\r\n'],
451
+ });
452
+ createdIds.push(result.id);
453
+ const session = sessions.get(result.id);
454
+ assert.ok(session);
455
+ assert.ok(session.scrollback.length >= 1);
456
+ assert.strictEqual(session.scrollback[0], 'prior output\r\n');
457
+ });
458
+ });
459
+ describe('session persistence', () => {
460
+ let tmpDir;
461
+ afterEach(() => {
462
+ // Clean up any sessions created during tests
463
+ for (const s of sessions.list()) {
464
+ try {
465
+ sessions.kill(s.id);
466
+ }
467
+ catch { /* ignore */ }
468
+ }
469
+ // Clean up temp directory
470
+ if (tmpDir) {
471
+ try {
472
+ fs.rmSync(tmpDir, { recursive: true, force: true });
473
+ }
474
+ catch { /* ignore */ }
475
+ }
476
+ });
477
+ function createTmpDir() {
478
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'crc-test-'));
479
+ return tmpDir;
480
+ }
481
+ it('serializeAll writes pending-sessions.json and scrollback files', () => {
482
+ const configDir = createTmpDir();
483
+ const s = sessions.create({
484
+ repoName: 'test-repo',
485
+ repoPath: '/tmp',
486
+ command: '/bin/cat',
487
+ args: [],
488
+ });
489
+ // Manually push some scrollback
490
+ const session = sessions.get(s.id);
491
+ assert.ok(session);
492
+ session.scrollback.push('hello world');
493
+ serializeAll(configDir);
494
+ // Check pending-sessions.json
495
+ const pendingPath = path.join(configDir, 'pending-sessions.json');
496
+ assert.ok(fs.existsSync(pendingPath), 'pending-sessions.json should exist');
497
+ const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
498
+ assert.strictEqual(pending.version, 1);
499
+ assert.ok(pending.timestamp);
500
+ assert.strictEqual(pending.sessions.length, 1);
501
+ assert.strictEqual(pending.sessions[0].id, s.id);
502
+ assert.strictEqual(pending.sessions[0].repoPath, '/tmp');
503
+ // Check scrollback file
504
+ const scrollbackPath = path.join(configDir, 'scrollback', s.id + '.buf');
505
+ assert.ok(fs.existsSync(scrollbackPath), 'scrollback file should exist');
506
+ const scrollbackData = fs.readFileSync(scrollbackPath, 'utf-8');
507
+ assert.ok(scrollbackData.includes('hello world'));
508
+ });
509
+ it('restoreFromDisk restores sessions with original IDs', async () => {
510
+ const configDir = createTmpDir();
511
+ // Create and serialize a session
512
+ const s = sessions.create({
513
+ repoName: 'test-repo',
514
+ repoPath: '/tmp',
515
+ command: '/bin/cat',
516
+ args: [],
517
+ displayName: 'my-session',
518
+ });
519
+ const originalId = s.id;
520
+ const session = sessions.get(originalId);
521
+ assert.ok(session);
522
+ session.scrollback.push('saved output');
523
+ serializeAll(configDir);
524
+ // Kill the original session
525
+ sessions.kill(originalId);
526
+ assert.strictEqual(sessions.get(originalId), undefined);
527
+ // Restore
528
+ const restored = await restoreFromDisk(configDir);
529
+ assert.strictEqual(restored, 1);
530
+ // Verify session exists with original ID
531
+ const restoredSession = sessions.get(originalId);
532
+ assert.ok(restoredSession, 'restored session should exist');
533
+ assert.strictEqual(restoredSession.repoPath, '/tmp');
534
+ assert.strictEqual(restoredSession.displayName, 'my-session');
535
+ // Scrollback should be restored
536
+ assert.ok(restoredSession.scrollback.length >= 1);
537
+ assert.strictEqual(restoredSession.scrollback[0], 'saved output');
538
+ // pending-sessions.json should be cleaned up
539
+ assert.ok(!fs.existsSync(path.join(configDir, 'pending-sessions.json')));
540
+ });
541
+ it('restoreFromDisk ignores stale files (>5 min old)', async () => {
542
+ const configDir = createTmpDir();
543
+ // Write a stale pending file
544
+ const staleTime = new Date(Date.now() - 6 * 60 * 1000).toISOString();
545
+ const pending = {
546
+ version: 1,
547
+ timestamp: staleTime,
548
+ sessions: [{ id: 'stale-id', type: 'repo', agent: 'claude', root: '', repoName: 'test', repoPath: '/tmp', worktreeName: '', branchName: '', displayName: 'test', createdAt: staleTime, lastActivity: staleTime, useTmux: false, tmuxSessionName: '', customCommand: null, cwd: '/tmp' }],
549
+ };
550
+ fs.writeFileSync(path.join(configDir, 'pending-sessions.json'), JSON.stringify(pending));
551
+ const restored = await restoreFromDisk(configDir);
552
+ assert.strictEqual(restored, 0, 'should not restore stale sessions');
553
+ assert.ok(!fs.existsSync(path.join(configDir, 'pending-sessions.json')), 'stale file should be deleted');
554
+ });
555
+ it('restoreFromDisk handles missing scrollback gracefully', async () => {
556
+ const configDir = createTmpDir();
557
+ // Create a session, serialize, then delete scrollback file
558
+ const s = sessions.create({
559
+ repoName: 'test-repo',
560
+ repoPath: '/tmp',
561
+ command: '/bin/cat',
562
+ args: [],
563
+ });
564
+ serializeAll(configDir);
565
+ sessions.kill(s.id);
566
+ // Delete scrollback file
567
+ const scrollbackPath = path.join(configDir, 'scrollback', s.id + '.buf');
568
+ try {
569
+ fs.unlinkSync(scrollbackPath);
570
+ }
571
+ catch { /* ignore */ }
572
+ const restored = await restoreFromDisk(configDir);
573
+ assert.strictEqual(restored, 1, 'should still restore without scrollback');
574
+ });
575
+ it('restoreFromDisk returns 0 when no pending file exists', async () => {
576
+ const configDir = createTmpDir();
577
+ const restored = await restoreFromDisk(configDir);
578
+ assert.strictEqual(restored, 0);
579
+ });
428
580
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "2.15.5",
3
+ "version": "2.15.6",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",