claude-remote-cli 1.6.0 → 1.7.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.
@@ -1,4 +1,6 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
2
4
  export const DEFAULTS = {
3
5
  host: '0.0.0.0',
4
6
  port: 3456,
@@ -18,3 +20,30 @@ export function loadConfig(configPath) {
18
20
  export function saveConfig(configPath, config) {
19
21
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
20
22
  }
23
+ function metaDir(configPath) {
24
+ return path.join(path.dirname(configPath), 'worktree-meta');
25
+ }
26
+ function metaFilePath(configPath, worktreePath) {
27
+ const hash = crypto.createHash('sha256').update(worktreePath).digest('hex').slice(0, 16);
28
+ return path.join(metaDir(configPath), hash + '.json');
29
+ }
30
+ export function ensureMetaDir(configPath) {
31
+ const dir = metaDir(configPath);
32
+ if (!fs.existsSync(dir)) {
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ }
35
+ }
36
+ export function readMeta(configPath, worktreePath) {
37
+ const fp = metaFilePath(configPath, worktreePath);
38
+ try {
39
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
40
+ }
41
+ catch (_) {
42
+ return null;
43
+ }
44
+ }
45
+ export function writeMeta(configPath, meta) {
46
+ const fp = metaFilePath(configPath, meta.worktreePath);
47
+ ensureMetaDir(configPath);
48
+ fs.writeFileSync(fp, JSON.stringify(meta, null, 2), 'utf8');
49
+ }
@@ -8,7 +8,7 @@ import { execFile } from 'node:child_process';
8
8
  import { promisify } from 'node:util';
9
9
  import express from 'express';
10
10
  import cookieParser from 'cookie-parser';
11
- import { loadConfig, saveConfig, DEFAULTS } from './config.js';
11
+ import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, ensureMetaDir } from './config.js';
12
12
  import * as auth from './auth.js';
13
13
  import * as sessions from './sessions.js';
14
14
  import { setupWebSocket } from './ws.js';
@@ -21,6 +21,8 @@ const execFileAsync = promisify(execFile);
21
21
  // When run via CLI bin, config lives in ~/.config/claude-remote-cli/
22
22
  // When run directly (development), fall back to local config.json
23
23
  const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', '..', 'config.json');
24
+ // Ensure worktree metadata directory exists alongside config
25
+ ensureMetaDir(CONFIG_PATH);
24
26
  const VERSION_CACHE_TTL = 5 * 60 * 1000;
25
27
  let versionCache = null;
26
28
  function getCurrentVersion() {
@@ -216,12 +218,16 @@ async function main() {
216
218
  for (const entry of entries) {
217
219
  if (!entry.isDirectory())
218
220
  continue;
221
+ const wtPath = path.join(worktreeDir, entry.name);
222
+ const meta = readMeta(CONFIG_PATH, wtPath);
219
223
  worktrees.push({
220
224
  name: entry.name,
221
- path: path.join(worktreeDir, entry.name),
225
+ path: wtPath,
222
226
  repoName: repo.name,
223
227
  repoPath: repo.path,
224
228
  root: repo.root,
229
+ displayName: meta ? meta.displayName : '',
230
+ lastActivity: meta ? meta.lastActivity : '',
225
231
  });
226
232
  }
227
233
  }
@@ -327,8 +333,8 @@ async function main() {
327
333
  let cwd;
328
334
  let worktreeName;
329
335
  if (worktreePath) {
330
- // Resume existing worktree — run claude inside the worktree directory
331
- args = [...baseArgs];
336
+ // Resume existing worktree — run claude --continue inside the worktree directory
337
+ args = ['--continue', ...baseArgs];
332
338
  cwd = worktreePath;
333
339
  worktreeName = worktreePath.split('/').pop() || '';
334
340
  }
@@ -346,6 +352,7 @@ async function main() {
346
352
  displayName: worktreeName,
347
353
  command: config.claudeCommand,
348
354
  args,
355
+ configPath: CONFIG_PATH,
349
356
  });
350
357
  res.status(201).json(session);
351
358
  });
@@ -359,7 +366,7 @@ async function main() {
359
366
  res.status(404).json({ error: 'Session not found' });
360
367
  }
361
368
  });
362
- // PATCH /sessions/:id — update displayName and send /rename through PTY
369
+ // PATCH /sessions/:id — update displayName and persist to metadata
363
370
  app.patch('/sessions/:id', requireAuth, (req, res) => {
364
371
  const { displayName } = req.body;
365
372
  if (!displayName) {
@@ -370,8 +377,8 @@ async function main() {
370
377
  const id = req.params['id'];
371
378
  const updated = sessions.updateDisplayName(id, displayName);
372
379
  const session = sessions.get(id);
373
- if (session && session.pty) {
374
- session.pty.write('/rename "' + displayName.replace(/"/g, '\\"') + '"\r');
380
+ if (session) {
381
+ writeMeta(CONFIG_PATH, { worktreePath: session.repoPath, displayName, lastActivity: session.lastActivity });
375
382
  }
376
383
  res.json(updated);
377
384
  }
@@ -3,9 +3,10 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+ import { readMeta, writeMeta } from './config.js';
6
7
  // In-memory registry: id -> Session
7
8
  const sessions = new Map();
8
- function create({ repoName, repoPath, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24 }) {
9
+ function create({ repoName, repoPath, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
9
10
  const id = crypto.randomBytes(8).toString('hex');
10
11
  const createdAt = new Date().toISOString();
11
12
  // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
@@ -35,6 +36,15 @@ function create({ repoName, repoPath, root, worktreeName, displayName, command,
35
36
  scrollback,
36
37
  };
37
38
  sessions.set(id, session);
39
+ // Load existing metadata to preserve a previously-set displayName
40
+ if (configPath && worktreeName) {
41
+ const existing = readMeta(configPath, repoPath);
42
+ if (existing && existing.displayName) {
43
+ session.displayName = existing.displayName;
44
+ }
45
+ writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: createdAt });
46
+ }
47
+ let metaFlushTimer = null;
38
48
  ptyProcess.onData((data) => {
39
49
  session.lastActivity = new Date().toISOString();
40
50
  scrollback.push(data);
@@ -43,8 +53,19 @@ function create({ repoName, repoPath, root, worktreeName, displayName, command,
43
53
  while (scrollbackBytes > MAX_SCROLLBACK && scrollback.length > 1) {
44
54
  scrollbackBytes -= scrollback.shift().length;
45
55
  }
56
+ if (configPath && worktreeName && !metaFlushTimer) {
57
+ metaFlushTimer = setTimeout(() => {
58
+ metaFlushTimer = null;
59
+ writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
60
+ }, 5000);
61
+ }
46
62
  });
47
63
  ptyProcess.onExit(() => {
64
+ if (metaFlushTimer)
65
+ clearTimeout(metaFlushTimer);
66
+ if (configPath && worktreeName) {
67
+ writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
68
+ }
48
69
  sessions.delete(id);
49
70
  const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
50
71
  fs.rm(tmpDir, { recursive: true, force: true }, () => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
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
@@ -155,6 +155,60 @@
155
155
  }
156
156
  });
157
157
 
158
+ // On Windows/Linux, Ctrl+V is the paste shortcut but xterm.js intercepts it
159
+ // internally without firing a native paste event, so our image paste handler
160
+ // on terminalContainer never runs. Intercept Ctrl+V here to check for images.
161
+ // On macOS, Ctrl+V sends a raw \x16 to the terminal (used by vim etc.), so
162
+ // we only intercept on non-Mac platforms.
163
+ var isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || '');
164
+ if (!isMac) {
165
+ term.attachCustomKeyEventHandler(function (e) {
166
+ if (e.type === 'keydown' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey &&
167
+ (e.key === 'v' || e.key === 'V')) {
168
+ if (navigator.clipboard && navigator.clipboard.read) {
169
+ navigator.clipboard.read().then(function (clipboardItems) {
170
+ var imageBlob = null;
171
+ var imageType = null;
172
+
173
+ for (var i = 0; i < clipboardItems.length; i++) {
174
+ var types = clipboardItems[i].types;
175
+ for (var j = 0; j < types.length; j++) {
176
+ if (types[j].indexOf('image/') === 0) {
177
+ imageType = types[j];
178
+ imageBlob = clipboardItems[i];
179
+ break;
180
+ }
181
+ }
182
+ if (imageBlob) break;
183
+ }
184
+
185
+ if (imageBlob) {
186
+ imageBlob.getType(imageType).then(function (blob) {
187
+ uploadImage(blob, imageType);
188
+ });
189
+ } else {
190
+ navigator.clipboard.readText().then(function (text) {
191
+ if (text) term.paste(text);
192
+ });
193
+ }
194
+ }).catch(function () {
195
+ // Clipboard read failed (permission denied, etc.) — fall back to text.
196
+ // If readText also fails, paste is lost for this keypress; this only
197
+ // happens when clipboard permission is fully denied, which is rare
198
+ // for user-gesture-triggered reads on HTTPS origins.
199
+ if (navigator.clipboard.readText) {
200
+ navigator.clipboard.readText().then(function (text) {
201
+ if (text) term.paste(text);
202
+ }).catch(function () {});
203
+ }
204
+ });
205
+ return false; // Prevent xterm from handling Ctrl+V
206
+ }
207
+ }
208
+ return true; // Let xterm handle all other keys
209
+ });
210
+ }
211
+
158
212
  var resizeObserver = new ResizeObserver(function () {
159
213
  fitAddon.fit();
160
214
  sendResize();
@@ -320,6 +374,24 @@
320
374
  return path.split('/').filter(Boolean).pop() || path;
321
375
  }
322
376
 
377
+ function formatRelativeTime(isoString) {
378
+ if (!isoString) return '';
379
+ var now = Date.now();
380
+ var then = new Date(isoString).getTime();
381
+ var diffSec = Math.floor((now - then) / 1000);
382
+ if (diffSec < 60) return 'just now';
383
+ var diffMin = Math.floor(diffSec / 60);
384
+ if (diffMin < 60) return diffMin + ' min ago';
385
+ var diffHr = Math.floor(diffMin / 60);
386
+ if (diffHr < 24) return diffHr + (diffHr === 1 ? ' hour ago' : ' hours ago');
387
+ var diffDay = Math.floor(diffHr / 24);
388
+ if (diffDay === 1) return 'yesterday';
389
+ if (diffDay < 7) return diffDay + ' days ago';
390
+ var d = new Date(isoString);
391
+ var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
392
+ return months[d.getMonth()] + ' ' + d.getDate();
393
+ }
394
+
323
395
  function renderUnifiedList() {
324
396
  var rootFilter = sidebarRootFilter.value;
325
397
  var repoFilter = sidebarRepoFilter.value;
@@ -396,6 +468,11 @@
396
468
  infoDiv.appendChild(nameSpan);
397
469
  infoDiv.appendChild(subSpan);
398
470
 
471
+ var timeSpan = document.createElement('span');
472
+ timeSpan.className = 'session-time';
473
+ timeSpan.textContent = formatRelativeTime(session.lastActivity);
474
+ infoDiv.appendChild(timeSpan);
475
+
399
476
  var actionsDiv = document.createElement('div');
400
477
  actionsDiv.className = 'session-actions';
401
478
 
@@ -439,8 +516,9 @@
439
516
 
440
517
  var nameSpan = document.createElement('span');
441
518
  nameSpan.className = 'session-name';
442
- nameSpan.textContent = wt.name;
443
- nameSpan.title = wt.name;
519
+ var wtDisplayName = wt.displayName || wt.name;
520
+ nameSpan.textContent = wtDisplayName;
521
+ nameSpan.title = wtDisplayName;
444
522
 
445
523
  var subSpan = document.createElement('span');
446
524
  subSpan.className = 'session-sub';
@@ -449,6 +527,11 @@
449
527
  infoDiv.appendChild(nameSpan);
450
528
  infoDiv.appendChild(subSpan);
451
529
 
530
+ var timeSpan = document.createElement('span');
531
+ timeSpan.className = 'session-time';
532
+ timeSpan.textContent = formatRelativeTime(wt.lastActivity);
533
+ infoDiv.appendChild(timeSpan);
534
+
452
535
  li.appendChild(infoDiv);
453
536
 
454
537
  // Click to resume (but not if context menu just opened or long-press fired)
package/public/style.css CHANGED
@@ -242,6 +242,10 @@ html, body {
242
242
  color: rgba(255, 255, 255, 0.7);
243
243
  }
244
244
 
245
+ #session-list li.active-session.active .session-time {
246
+ color: rgba(255, 255, 255, 0.5);
247
+ }
248
+
245
249
  /* Inactive worktree (outline) */
246
250
  #session-list li.inactive-worktree {
247
251
  background: transparent;
@@ -280,6 +284,15 @@ html, body {
280
284
  white-space: nowrap;
281
285
  }
282
286
 
287
+ .session-time {
288
+ font-size: 0.65rem;
289
+ color: var(--text-muted);
290
+ opacity: 0.6;
291
+ overflow: hidden;
292
+ text-overflow: ellipsis;
293
+ white-space: nowrap;
294
+ }
295
+
283
296
  .session-actions {
284
297
  display: flex;
285
298
  align-items: center;