claude-remote-cli 1.8.0 → 1.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.
@@ -6,6 +6,11 @@ import path from 'node:path';
6
6
  import { readMeta, writeMeta } from './config.js';
7
7
  // In-memory registry: id -> Session
8
8
  const sessions = new Map();
9
+ const IDLE_TIMEOUT_MS = 5000;
10
+ let idleChangeCallback = null;
11
+ function onIdleChange(cb) {
12
+ idleChangeCallback = cb;
13
+ }
9
14
  function create({ repoName, repoPath, cwd, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
10
15
  const id = crypto.randomBytes(8).toString('hex');
11
16
  const createdAt = new Date().toISOString();
@@ -34,6 +39,7 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
34
39
  createdAt,
35
40
  lastActivity: createdAt,
36
41
  scrollback,
42
+ idle: false,
37
43
  };
38
44
  sessions.set(id, session);
39
45
  // Load existing metadata to preserve a previously-set displayName
@@ -45,8 +51,26 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
45
51
  writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: createdAt });
46
52
  }
47
53
  let metaFlushTimer = null;
54
+ let idleTimer = null;
55
+ function resetIdleTimer() {
56
+ if (session.idle) {
57
+ session.idle = false;
58
+ if (idleChangeCallback)
59
+ idleChangeCallback(session.id, false);
60
+ }
61
+ if (idleTimer)
62
+ clearTimeout(idleTimer);
63
+ idleTimer = setTimeout(() => {
64
+ if (!session.idle) {
65
+ session.idle = true;
66
+ if (idleChangeCallback)
67
+ idleChangeCallback(session.id, true);
68
+ }
69
+ }, IDLE_TIMEOUT_MS);
70
+ }
48
71
  ptyProcess.onData((data) => {
49
72
  session.lastActivity = new Date().toISOString();
73
+ resetIdleTimer();
50
74
  scrollback.push(data);
51
75
  scrollbackBytes += data.length;
52
76
  // Trim oldest entries if over limit
@@ -61,6 +85,8 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
61
85
  }
62
86
  });
63
87
  ptyProcess.onExit(() => {
88
+ if (idleTimer)
89
+ clearTimeout(idleTimer);
64
90
  if (metaFlushTimer)
65
91
  clearTimeout(metaFlushTimer);
66
92
  if (configPath && worktreeName) {
@@ -70,14 +96,14 @@ function create({ repoName, repoPath, cwd, root, worktreeName, displayName, comm
70
96
  const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
71
97
  fs.rm(tmpDir, { recursive: true, force: true }, () => { });
72
98
  });
73
- return { id, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt };
99
+ return { id, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false };
74
100
  }
75
101
  function get(id) {
76
102
  return sessions.get(id);
77
103
  }
78
104
  function list() {
79
105
  return Array.from(sessions.values())
80
- .map(({ id, root, repoName, repoPath, worktreeName, displayName, createdAt, lastActivity }) => ({
106
+ .map(({ id, root, repoName, repoPath, worktreeName, displayName, createdAt, lastActivity, idle }) => ({
81
107
  id,
82
108
  root,
83
109
  repoName,
@@ -86,6 +112,7 @@ function list() {
86
112
  displayName,
87
113
  createdAt,
88
114
  lastActivity,
115
+ idle,
89
116
  }))
90
117
  .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
91
118
  }
@@ -118,4 +145,4 @@ function write(id, data) {
118
145
  }
119
146
  session.pty.write(data);
120
147
  }
121
- export { create, get, list, kill, resize, updateDisplayName, write };
148
+ export { create, get, list, kill, resize, updateDisplayName, write, onIdleChange };
package/dist/server/ws.js CHANGED
@@ -17,8 +17,8 @@ function parseCookies(cookieHeader) {
17
17
  function setupWebSocket(server, authenticatedTokens, watcher) {
18
18
  const wss = new WebSocketServer({ noServer: true });
19
19
  const eventClients = new Set();
20
- function broadcastEvent(type) {
21
- const msg = JSON.stringify({ type });
20
+ function broadcastEvent(type, data) {
21
+ const msg = JSON.stringify({ type, ...data });
22
22
  for (const client of eventClients) {
23
23
  if (client.readyState === client.OPEN) {
24
24
  client.send(msg);
@@ -40,8 +40,10 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
40
40
  // Event channel: /ws/events
41
41
  if (request.url === '/ws/events') {
42
42
  wss.handleUpgrade(request, socket, head, (ws) => {
43
+ const cleanup = () => { eventClients.delete(ws); };
43
44
  eventClients.add(ws);
44
- ws.on('close', () => { eventClients.delete(ws); });
45
+ ws.on('close', cleanup);
46
+ ws.on('error', cleanup);
45
47
  });
46
48
  return;
47
49
  }
@@ -99,6 +101,9 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
99
101
  }
100
102
  });
101
103
  });
104
+ sessions.onIdleChange((sessionId, idle) => {
105
+ broadcastEvent('session-idle-changed', { sessionId, idle });
106
+ });
102
107
  return { wss, broadcastEvent };
103
108
  }
104
109
  export { setupWebSocket };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
package/public/app.js CHANGED
@@ -6,6 +6,8 @@
6
6
  var ws = null;
7
7
  var term = null;
8
8
  var fitAddon = null;
9
+ var reconnectTimer = null;
10
+ var reconnectAttempt = 0;
9
11
 
10
12
  var wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
11
13
 
@@ -87,6 +89,7 @@
87
89
  var cachedSessions = [];
88
90
  var cachedWorktrees = [];
89
91
  var allRepos = [];
92
+ var attentionSessions = {};
90
93
 
91
94
  // ── PIN Auth ────────────────────────────────────────────────────────────────
92
95
 
@@ -312,38 +315,92 @@
312
315
  // ── WebSocket / Session Connection ──────────────────────────────────────────
313
316
 
314
317
  function connectToSession(sessionId) {
318
+ if (reconnectTimer) {
319
+ clearTimeout(reconnectTimer);
320
+ reconnectTimer = null;
321
+ }
322
+ reconnectAttempt = 0;
323
+
315
324
  if (ws) {
325
+ ws.onclose = null;
316
326
  ws.close();
317
327
  ws = null;
318
328
  }
319
329
 
320
330
  activeSessionId = sessionId;
331
+ delete attentionSessions[sessionId];
321
332
  noSessionMsg.hidden = true;
322
333
  term.clear();
323
334
  term.focus();
324
335
  closeSidebar();
325
336
  updateSessionTitle();
337
+ highlightActiveSession();
338
+
339
+ openPtyWebSocket(sessionId);
340
+ }
326
341
 
342
+ function openPtyWebSocket(sessionId) {
327
343
  var url = wsProtocol + '//' + location.host + '/ws/' + sessionId;
328
- ws = new WebSocket(url);
344
+ var socket = new WebSocket(url);
329
345
 
330
- ws.onopen = function () {
346
+ socket.onopen = function () {
347
+ ws = socket;
348
+ reconnectAttempt = 0;
331
349
  sendResize();
332
350
  };
333
351
 
334
- ws.onmessage = function (event) {
352
+ socket.onmessage = function (event) {
335
353
  term.write(event.data);
336
354
  };
337
355
 
338
- ws.onclose = function () {
339
- term.write('\r\n[Connection closed]\r\n');
340
- };
356
+ socket.onclose = function (event) {
357
+ if (event.code === 1000) {
358
+ term.write('\r\n[Session ended]\r\n');
359
+ ws = null;
360
+ return;
361
+ }
341
362
 
342
- ws.onerror = function () {
343
- term.write('\r\n[WebSocket error]\r\n');
363
+ if (activeSessionId !== sessionId) return;
364
+
365
+ ws = null;
366
+ if (reconnectAttempt === 0) {
367
+ term.write('\r\n[Reconnecting...]\r\n');
368
+ }
369
+ scheduleReconnect(sessionId);
344
370
  };
345
371
 
346
- highlightActiveSession();
372
+ socket.onerror = function () {};
373
+ }
374
+
375
+ var MAX_RECONNECT_ATTEMPTS = 30;
376
+
377
+ function scheduleReconnect(sessionId) {
378
+ if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
379
+ term.write('\r\n[Gave up reconnecting after ' + MAX_RECONNECT_ATTEMPTS + ' attempts]\r\n');
380
+ return;
381
+ }
382
+ var delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 10000);
383
+ reconnectAttempt++;
384
+
385
+ reconnectTimer = setTimeout(function () {
386
+ reconnectTimer = null;
387
+ if (activeSessionId !== sessionId) return;
388
+ fetch('/sessions').then(function (res) {
389
+ return res.json();
390
+ }).then(function (sessions) {
391
+ var exists = sessions.some(function (s) { return s.id === sessionId; });
392
+ if (!exists || activeSessionId !== sessionId) {
393
+ term.write('\r\n[Session ended]\r\n');
394
+ return;
395
+ }
396
+ term.clear();
397
+ openPtyWebSocket(sessionId);
398
+ }).catch(function () {
399
+ if (activeSessionId === sessionId) {
400
+ scheduleReconnect(sessionId);
401
+ }
402
+ });
403
+ }, delay);
347
404
  }
348
405
 
349
406
  // ── Sessions & Worktrees ────────────────────────────────────────────────────
@@ -365,6 +422,14 @@
365
422
  if (msg.type === 'worktrees-changed') {
366
423
  loadRepos();
367
424
  refreshAll();
425
+ } else if (msg.type === 'session-idle-changed') {
426
+ if (msg.idle && msg.sessionId !== activeSessionId) {
427
+ attentionSessions[msg.sessionId] = true;
428
+ }
429
+ if (!msg.idle) {
430
+ delete attentionSessions[msg.sessionId];
431
+ }
432
+ renderUnifiedList();
368
433
  }
369
434
  } catch (_) {}
370
435
  };
@@ -386,6 +451,14 @@
386
451
  .then(function (results) {
387
452
  cachedSessions = results[0] || [];
388
453
  cachedWorktrees = results[1] || [];
454
+
455
+ // Prune attention flags for sessions that no longer exist
456
+ var activeIds = {};
457
+ cachedSessions.forEach(function (s) { activeIds[s.id] = true; });
458
+ Object.keys(attentionSessions).forEach(function (id) {
459
+ if (!activeIds[id]) delete attentionSessions[id];
460
+ });
461
+
389
462
  populateSidebarFilters();
390
463
  renderUnifiedList();
391
464
  })
@@ -534,6 +607,12 @@
534
607
  highlightActiveSession();
535
608
  }
536
609
 
610
+ function getSessionStatus(session) {
611
+ if (attentionSessions[session.id]) return 'attention';
612
+ if (session.idle) return 'idle';
613
+ return 'running';
614
+ }
615
+
537
616
  function createActiveSessionLi(session) {
538
617
  var li = document.createElement('li');
539
618
  li.className = 'active-session';
@@ -552,6 +631,10 @@
552
631
  subSpan.className = 'session-sub';
553
632
  subSpan.textContent = (session.root ? rootShortName(session.root) : '') + ' · ' + (session.repoName || '');
554
633
 
634
+ var status = getSessionStatus(session);
635
+ var dot = document.createElement('span');
636
+ dot.className = 'status-dot status-dot--' + status;
637
+ infoDiv.appendChild(dot);
555
638
  infoDiv.appendChild(nameSpan);
556
639
  infoDiv.appendChild(subSpan);
557
640
 
@@ -611,6 +694,9 @@
611
694
  subSpan.className = 'session-sub';
612
695
  subSpan.textContent = (wt.root ? rootShortName(wt.root) : '') + ' · ' + (wt.repoName || '');
613
696
 
697
+ var dot = document.createElement('span');
698
+ dot.className = 'status-dot status-dot--inactive';
699
+ infoDiv.appendChild(dot);
614
700
  infoDiv.appendChild(nameSpan);
615
701
  infoDiv.appendChild(subSpan);
616
702
 
package/public/style.css CHANGED
@@ -261,10 +261,12 @@ html, body {
261
261
 
262
262
  .session-info {
263
263
  display: flex;
264
- flex-direction: column;
264
+ flex-direction: row;
265
+ flex-wrap: wrap;
265
266
  gap: 2px;
266
267
  min-width: 0;
267
268
  flex: 1;
269
+ align-items: center;
268
270
  }
269
271
 
270
272
  .session-name {
@@ -276,6 +278,39 @@ html, body {
276
278
  color: var(--text);
277
279
  }
278
280
 
281
+ .status-dot {
282
+ display: inline-block;
283
+ width: 8px;
284
+ height: 8px;
285
+ border-radius: 50%;
286
+ flex-shrink: 0;
287
+ margin-right: 8px;
288
+ margin-top: 2px;
289
+ }
290
+
291
+ .status-dot--running {
292
+ background: #4ade80;
293
+ }
294
+
295
+ .status-dot--idle {
296
+ background: #60a5fa;
297
+ }
298
+
299
+ .status-dot--attention {
300
+ background: #f59e0b;
301
+ box-shadow: 0 0 6px 2px rgba(245, 158, 11, 0.5);
302
+ animation: attention-glow 2s ease-in-out infinite;
303
+ }
304
+
305
+ @keyframes attention-glow {
306
+ 0%, 100% { box-shadow: 0 0 4px 1px rgba(245, 158, 11, 0.3); }
307
+ 50% { box-shadow: 0 0 8px 3px rgba(245, 158, 11, 0.6); }
308
+ }
309
+
310
+ .status-dot--inactive {
311
+ background: #6b7280;
312
+ }
313
+
279
314
  .session-sub {
280
315
  font-size: 0.7rem;
281
316
  color: var(--text-muted);
@@ -293,6 +328,11 @@ html, body {
293
328
  white-space: nowrap;
294
329
  }
295
330
 
331
+ .session-sub, .session-time {
332
+ width: 100%;
333
+ padding-left: 16px; /* aligns with name text: status-dot 8px + margin-right 8px */
334
+ }
335
+
296
336
  .session-actions {
297
337
  display: flex;
298
338
  align-items: center;