claude-remote-cli 1.9.5 → 2.0.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
@@ -142,9 +142,10 @@ The PIN hash is stored in config under `pinHash`. To reset:
142
142
  ## Features
143
143
 
144
144
  - **PIN-protected access** with rate limiting
145
- - **Worktree isolation** — each session runs in its own Claude Code `--worktree`
145
+ - **Branch-aware sessions** — create worktrees from new or existing branches with a type-to-search branch picker
146
+ - **Worktree isolation** — each session runs in its own git worktree under `.worktrees/`
146
147
  - **Resume sessions** — click inactive worktrees to reconnect with `--continue`
147
- - **Persistent session names** — display names and timestamps survive server restarts
148
+ - **Persistent session names** — display names, branch names, and timestamps survive server restarts
148
149
  - **Clipboard image paste** — paste screenshots directly into remote terminal sessions (macOS clipboard + xclip on Linux)
149
150
  - **Yolo mode** — skip permission prompts with `--dangerously-skip-permissions` (per-session checkbox or context menu)
150
151
  - **Worktree cleanup** — delete inactive worktrees from the context menu (removes worktree, prunes refs, deletes branch)
@@ -169,7 +170,7 @@ claude-remote-cli/
169
170
  │ ├── index.ts # Express server, REST API routes
170
171
  │ ├── sessions.ts # PTY session manager (node-pty)
171
172
  │ ├── ws.ts # WebSocket relay (PTY ↔ browser)
172
- │ ├── watcher.ts # File watcher for .claude/worktrees/ changes
173
+ │ ├── watcher.ts # File watcher for .worktrees/ changes
173
174
  │ ├── auth.ts # PIN hashing, verification, rate limiting
174
175
  │ ├── config.ts # Config loading/saving, worktree metadata
175
176
  │ ├── clipboard.ts # System clipboard operations (image paste)
@@ -109,6 +109,24 @@ function scanAllRepos(rootDirs) {
109
109
  }
110
110
  return repos;
111
111
  }
112
+ function ensureGitignore(repoPath, entry) {
113
+ const gitignorePath = path.join(repoPath, '.gitignore');
114
+ try {
115
+ if (fs.existsSync(gitignorePath)) {
116
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
117
+ if (content.split('\n').some((line) => line.trim() === entry))
118
+ return;
119
+ const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
120
+ fs.appendFileSync(gitignorePath, prefix + entry + '\n');
121
+ }
122
+ else {
123
+ fs.writeFileSync(gitignorePath, entry + '\n');
124
+ }
125
+ }
126
+ catch (_) {
127
+ // Non-fatal: gitignore update failure shouldn't block session creation
128
+ }
129
+ }
112
130
  async function main() {
113
131
  let config;
114
132
  try {
@@ -193,6 +211,27 @@ async function main() {
193
211
  }
194
212
  res.json(repos);
195
213
  });
214
+ // GET /branches?repo=<path> — list local and remote branches for a repo
215
+ app.get('/branches', requireAuth, async (req, res) => {
216
+ const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
217
+ if (!repoPath) {
218
+ res.status(400).json({ error: 'repo query parameter is required' });
219
+ return;
220
+ }
221
+ try {
222
+ const { stdout } = await execFileAsync('git', ['branch', '-a', '--format=%(refname:short)'], { cwd: repoPath });
223
+ const branches = stdout
224
+ .split('\n')
225
+ .map((b) => b.trim())
226
+ .filter((b) => b && !b.includes('HEAD'))
227
+ .map((b) => b.replace(/^origin\//, ''));
228
+ const unique = [...new Set(branches)];
229
+ res.json(unique.sort());
230
+ }
231
+ catch (_) {
232
+ res.json([]);
233
+ }
234
+ });
196
235
  // GET /worktrees?repo=<path> — list worktrees; omit repo to scan all repos in all rootDirs
197
236
  app.get('/worktrees', requireAuth, (req, res) => {
198
237
  const repoParam = typeof req.query.repo === 'string' ? req.query.repo : undefined;
@@ -207,7 +246,7 @@ async function main() {
207
246
  reposToScan = scanAllRepos(roots);
208
247
  }
209
248
  for (const repo of reposToScan) {
210
- const worktreeDir = path.join(repo.path, '.claude', 'worktrees');
249
+ const worktreeDir = path.join(repo.path, '.worktrees');
211
250
  let entries;
212
251
  try {
213
252
  entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
@@ -276,9 +315,9 @@ async function main() {
276
315
  res.status(400).json({ error: 'worktreePath and repoPath are required' });
277
316
  return;
278
317
  }
279
- // Validate the path is inside a .claude/worktrees/ directory
280
- if (!worktreePath.includes(path.sep + '.claude' + path.sep + 'worktrees' + path.sep)) {
281
- res.status(400).json({ error: 'Path is not inside a .claude/worktrees/ directory' });
318
+ // Validate the path is inside a .worktrees/ directory
319
+ if (!worktreePath.includes(path.sep + '.worktrees' + path.sep)) {
320
+ res.status(400).json({ error: 'Path is not inside a .worktrees/ directory' });
282
321
  return;
283
322
  }
284
323
  // Check no active session is using this worktree
@@ -288,14 +327,16 @@ async function main() {
288
327
  res.status(409).json({ error: 'Close the active session first' });
289
328
  return;
290
329
  }
291
- // Derive branch name from worktree directory name
292
- const branchName = worktreePath.split('/').pop() || '';
330
+ // Derive branch name from metadata or worktree directory name
331
+ const meta = readMeta(CONFIG_PATH, worktreePath);
332
+ const branchName = (meta && meta.branchName) || worktreePath.split('/').pop() || '';
293
333
  try {
294
- // Remove the worktree (will fail if uncommitted changes no --force)
334
+ // Will fail if uncommitted changes -- no --force
295
335
  await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: repoPath });
296
336
  }
297
337
  catch (err) {
298
- const message = err instanceof Error ? err.message : 'Failed to remove worktree';
338
+ const execErr = err;
339
+ const message = (execErr.stderr || execErr.message || 'Failed to remove worktree').trim();
299
340
  res.status(500).json({ error: message });
300
341
  return;
301
342
  }
@@ -318,8 +359,8 @@ async function main() {
318
359
  res.json({ ok: true });
319
360
  });
320
361
  // POST /sessions
321
- app.post('/sessions', requireAuth, (req, res) => {
322
- const { repoPath, repoName, worktreePath, claudeArgs } = req.body;
362
+ app.post('/sessions', requireAuth, async (req, res) => {
363
+ const { repoPath, repoName, worktreePath, branchName, claudeArgs } = req.body;
323
364
  if (!repoPath) {
324
365
  res.status(400).json({ error: 'repoPath is required' });
325
366
  return;
@@ -333,32 +374,89 @@ async function main() {
333
374
  let cwd;
334
375
  let worktreeName;
335
376
  let sessionRepoPath;
377
+ let resolvedBranch = '';
336
378
  if (worktreePath) {
337
- // Resume existing worktree — run claude --continue inside the worktree directory
379
+ // Resume existing worktree
338
380
  args = ['--continue', ...baseArgs];
339
381
  cwd = worktreePath;
340
382
  sessionRepoPath = worktreePath;
341
383
  worktreeName = worktreePath.split('/').pop() || '';
342
384
  }
343
385
  else {
344
- // New worktree — PTY spawns in the repo root (so `claude --worktree X` works),
345
- // but repoPath points to the expected worktree dir for identity/metadata matching
346
- worktreeName = 'mobile-' + name + '-' + Date.now().toString(36);
347
- args = ['--worktree', worktreeName, ...baseArgs];
348
- cwd = repoPath;
349
- sessionRepoPath = path.join(repoPath, '.claude', 'worktrees', worktreeName);
386
+ // Create new worktree via git
387
+ let dirName;
388
+ if (branchName) {
389
+ dirName = branchName.replace(/\//g, '-');
390
+ resolvedBranch = branchName;
391
+ }
392
+ else {
393
+ dirName = 'mobile-' + name + '-' + Date.now().toString(36);
394
+ resolvedBranch = dirName;
395
+ }
396
+ const worktreeDir = path.join(repoPath, '.worktrees');
397
+ let targetDir = path.join(worktreeDir, dirName);
398
+ if (fs.existsSync(targetDir)) {
399
+ targetDir = targetDir + '-' + Date.now().toString(36);
400
+ dirName = path.basename(targetDir);
401
+ }
402
+ ensureGitignore(repoPath, '.worktrees/');
403
+ try {
404
+ // Check if branch exists locally or on a remote
405
+ let branchExists = false;
406
+ if (branchName) {
407
+ const localCheck = await execFileAsync('git', ['rev-parse', '--verify', branchName], { cwd: repoPath }).then(() => true, () => false);
408
+ if (localCheck) {
409
+ branchExists = true;
410
+ }
411
+ else {
412
+ const remoteCheck = await execFileAsync('git', ['rev-parse', '--verify', 'origin/' + branchName], { cwd: repoPath }).then(() => true, () => false);
413
+ if (remoteCheck) {
414
+ branchExists = true;
415
+ resolvedBranch = 'origin/' + branchName;
416
+ }
417
+ }
418
+ }
419
+ if (branchName && branchExists) {
420
+ await execFileAsync('git', ['worktree', 'add', targetDir, resolvedBranch], { cwd: repoPath });
421
+ }
422
+ else if (branchName) {
423
+ await execFileAsync('git', ['worktree', 'add', '-b', branchName, targetDir, 'HEAD'], { cwd: repoPath });
424
+ }
425
+ else {
426
+ await execFileAsync('git', ['worktree', 'add', '-b', dirName, targetDir, 'HEAD'], { cwd: repoPath });
427
+ }
428
+ }
429
+ catch (err) {
430
+ const execErr = err;
431
+ const message = (execErr.stderr || execErr.message || 'Failed to create worktree').trim();
432
+ res.status(500).json({ error: message });
433
+ return;
434
+ }
435
+ worktreeName = dirName;
436
+ sessionRepoPath = targetDir;
437
+ cwd = targetDir;
438
+ args = [...baseArgs];
350
439
  }
440
+ const displayName = branchName || worktreeName;
351
441
  const session = sessions.create({
352
442
  repoName: name,
353
443
  repoPath: sessionRepoPath,
354
444
  cwd,
355
445
  root,
356
446
  worktreeName,
357
- displayName: worktreeName,
447
+ displayName,
358
448
  command: config.claudeCommand,
359
449
  args,
360
450
  configPath: CONFIG_PATH,
361
451
  });
452
+ if (!worktreePath) {
453
+ writeMeta(CONFIG_PATH, {
454
+ worktreePath: sessionRepoPath,
455
+ displayName,
456
+ lastActivity: new Date().toISOString(),
457
+ branchName: branchName || worktreeName,
458
+ });
459
+ }
362
460
  res.status(201).json(session);
363
461
  });
364
462
  // DELETE /sessions/:id
@@ -30,15 +30,13 @@ export class WorktreeWatcher extends EventEmitter {
30
30
  }
31
31
  }
32
32
  _watchRepo(repoPath) {
33
- const worktreeDir = path.join(repoPath, '.claude', 'worktrees');
33
+ const worktreeDir = path.join(repoPath, '.worktrees');
34
34
  if (fs.existsSync(worktreeDir)) {
35
35
  this._addWatch(worktreeDir);
36
36
  }
37
37
  else {
38
- const claudeDir = path.join(repoPath, '.claude');
39
- if (fs.existsSync(claudeDir)) {
40
- this._addWatch(claudeDir);
41
- }
38
+ // Watch the repo root so we detect when .worktrees/ is first created
39
+ this._addWatch(repoPath);
42
40
  }
43
41
  }
44
42
  _addWatch(dirPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.9.5",
3
+ "version": "2.0.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
@@ -37,6 +37,8 @@
37
37
  var dialogRootSelect = document.getElementById('dialog-root-select');
38
38
  var dialogRepoSelect = document.getElementById('dialog-repo-select');
39
39
  var dialogYolo = document.getElementById('dialog-yolo');
40
+ var dialogBranchInput = document.getElementById('dialog-branch-input');
41
+ var dialogBranchList = document.getElementById('dialog-branch-list');
40
42
  var contextMenu = document.getElementById('context-menu');
41
43
  var ctxResumeYolo = document.getElementById('ctx-resume-yolo');
42
44
  var ctxDeleteWorktree = document.getElementById('ctx-delete-worktree');
@@ -89,8 +91,82 @@
89
91
  var cachedSessions = [];
90
92
  var cachedWorktrees = [];
91
93
  var allRepos = [];
94
+ var allBranches = [];
92
95
  var attentionSessions = {};
93
96
 
97
+ function loadBranches(repoPath) {
98
+ allBranches = [];
99
+ dialogBranchList.innerHTML = '';
100
+ dialogBranchList.hidden = true;
101
+ if (!repoPath) return;
102
+
103
+ fetch('/branches?repo=' + encodeURIComponent(repoPath))
104
+ .then(function (res) {
105
+ if (!res.ok) return [];
106
+ return res.json();
107
+ })
108
+ .then(function (data) {
109
+ allBranches = data || [];
110
+ })
111
+ .catch(function () {
112
+ allBranches = [];
113
+ });
114
+ }
115
+
116
+ function filterBranches(query) {
117
+ dialogBranchList.innerHTML = '';
118
+ if (!query) {
119
+ dialogBranchList.hidden = true;
120
+ return;
121
+ }
122
+
123
+ var lower = query.toLowerCase();
124
+ var matches = allBranches.filter(function (b) {
125
+ return b.toLowerCase().indexOf(lower) !== -1;
126
+ }).slice(0, 10);
127
+
128
+ var exactMatch = allBranches.some(function (b) { return b === query; });
129
+
130
+ if (!exactMatch) {
131
+ var createLi = document.createElement('li');
132
+ createLi.className = 'branch-create-new';
133
+ createLi.textContent = 'Create new: ' + query;
134
+ createLi.addEventListener('click', function () {
135
+ dialogBranchInput.value = query;
136
+ dialogBranchList.hidden = true;
137
+ });
138
+ dialogBranchList.appendChild(createLi);
139
+ }
140
+
141
+ matches.forEach(function (branch) {
142
+ var li = document.createElement('li');
143
+ li.textContent = branch;
144
+ li.addEventListener('click', function () {
145
+ dialogBranchInput.value = branch;
146
+ dialogBranchList.hidden = true;
147
+ });
148
+ dialogBranchList.appendChild(li);
149
+ });
150
+
151
+ dialogBranchList.hidden = dialogBranchList.children.length === 0;
152
+ }
153
+
154
+ dialogBranchInput.addEventListener('input', function () {
155
+ filterBranches(dialogBranchInput.value.trim());
156
+ });
157
+
158
+ dialogBranchInput.addEventListener('focus', function () {
159
+ if (dialogBranchInput.value.trim()) {
160
+ filterBranches(dialogBranchInput.value.trim());
161
+ }
162
+ });
163
+
164
+ document.addEventListener('click', function (e) {
165
+ if (!dialogBranchInput.contains(e.target) && !dialogBranchList.contains(e.target)) {
166
+ dialogBranchList.hidden = true;
167
+ }
168
+ });
169
+
94
170
  // ── PIN Auth ────────────────────────────────────────────────────────────────
95
171
 
96
172
  function submitPin() {
@@ -917,6 +993,8 @@
917
993
  dialogRootSelect.addEventListener('change', function () {
918
994
  var root = dialogRootSelect.value;
919
995
  dialogRepoSelect.innerHTML = '<option value="">Select a repo...</option>';
996
+ dialogBranchInput.value = '';
997
+ allBranches = [];
920
998
 
921
999
  if (!root) {
922
1000
  dialogRepoSelect.disabled = true;
@@ -934,13 +1012,20 @@
934
1012
  dialogRepoSelect.disabled = false;
935
1013
  });
936
1014
 
937
- function startSession(repoPath, worktreePath, claudeArgs) {
1015
+ dialogRepoSelect.addEventListener('change', function () {
1016
+ var repoPath = dialogRepoSelect.value;
1017
+ dialogBranchInput.value = '';
1018
+ loadBranches(repoPath);
1019
+ });
1020
+
1021
+ function startSession(repoPath, worktreePath, claudeArgs, branchName) {
938
1022
  var body = {
939
1023
  repoPath: repoPath,
940
1024
  repoName: repoPath.split('/').filter(Boolean).pop(),
941
1025
  };
942
1026
  if (worktreePath) body.worktreePath = worktreePath;
943
1027
  if (claudeArgs) body.claudeArgs = claudeArgs;
1028
+ if (branchName) body.branchName = branchName;
944
1029
 
945
1030
  fetch('/sessions', {
946
1031
  method: 'POST',
@@ -961,6 +1046,9 @@
961
1046
  newSessionBtn.addEventListener('click', function () {
962
1047
  customPath.value = '';
963
1048
  dialogYolo.checked = false;
1049
+ dialogBranchInput.value = '';
1050
+ dialogBranchList.hidden = true;
1051
+ allBranches = [];
964
1052
  populateDialogRootSelect();
965
1053
 
966
1054
  var sidebarRoot = sidebarRootFilter.value;
@@ -985,10 +1073,18 @@
985
1073
  });
986
1074
 
987
1075
  dialogStart.addEventListener('click', function () {
988
- var path = customPath.value.trim() || dialogRepoSelect.value;
989
- if (!path) return;
1076
+ var repoPathValue = customPath.value.trim() || dialogRepoSelect.value;
1077
+ if (!repoPathValue) return;
990
1078
  var args = dialogYolo.checked ? ['--dangerously-skip-permissions'] : undefined;
991
- startSession(path, undefined, args);
1079
+ var branch = dialogBranchInput.value.trim() || undefined;
1080
+ startSession(repoPathValue, undefined, args, branch);
1081
+ });
1082
+
1083
+ customPath.addEventListener('blur', function () {
1084
+ var pathValue = customPath.value.trim();
1085
+ if (pathValue) {
1086
+ loadBranches(pathValue);
1087
+ }
992
1088
  });
993
1089
 
994
1090
  dialogCancel.addEventListener('click', function () {
@@ -1359,6 +1455,28 @@
1359
1455
  return count;
1360
1456
  }
1361
1457
 
1458
+ // Batched send: accumulates payload across rapid input events (e.g. autocorrect
1459
+ // fires deleteContentBackward + insertText ~2ms apart) and flushes in one
1460
+ // ws.send() so the PTY receives backspaces + replacement text atomically.
1461
+ var sendBuffer = '';
1462
+ var sendTimer = null;
1463
+ var SEND_DELAY = 10; // ms – enough to batch autocorrect pairs, imperceptible for typing
1464
+
1465
+ function scheduleSend(data) {
1466
+ sendBuffer += data;
1467
+ if (sendTimer !== null) clearTimeout(sendTimer);
1468
+ sendTimer = setTimeout(flushSendBuffer, SEND_DELAY);
1469
+ }
1470
+
1471
+ function flushSendBuffer() {
1472
+ sendTimer = null;
1473
+ if (sendBuffer && ws && ws.readyState === WebSocket.OPEN) {
1474
+ dbg('FLUSH: "' + sendBuffer.replace(/\x7f/g, '\u232b') + '" (' + sendBuffer.length + ' bytes)');
1475
+ ws.send(sendBuffer);
1476
+ }
1477
+ sendBuffer = '';
1478
+ }
1479
+
1362
1480
  // Send the diff between lastInputValue and currentValue to the terminal.
1363
1481
  // Handles autocorrect expansions, deletions, and same-length replacements.
1364
1482
  function sendInputDiff(currentValue) {
@@ -1374,11 +1492,13 @@
1374
1492
 
1375
1493
  dbg('sendInputDiff: del=' + charsToDelete + ' "' + deletedSlice + '" add="' + newChars + '"');
1376
1494
 
1495
+ var payload = '';
1377
1496
  for (var i = 0; i < charsToDelete; i++) {
1378
- ws.send('\x7f'); // backspace
1497
+ payload += '\x7f';
1379
1498
  }
1380
- if (newChars) {
1381
- ws.send(newChars);
1499
+ payload += newChars;
1500
+ if (payload) {
1501
+ scheduleSend(payload);
1382
1502
  }
1383
1503
  }
1384
1504
 
@@ -1413,9 +1533,7 @@
1413
1533
  lastInputValue = '';
1414
1534
  });
1415
1535
 
1416
- // Flush any pending composed text to the terminal.
1417
- // Safe to call even if compositionend already ran: sendInputDiff
1418
- // is a no-op when lastInputValue === mobileInput.value.
1536
+ // Flush any pending composed text and buffered sends to the terminal.
1419
1537
  function flushComposedText() {
1420
1538
  isComposing = false;
1421
1539
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -1423,6 +1541,7 @@
1423
1541
  sendInputDiff(currentValue);
1424
1542
  lastInputValue = currentValue;
1425
1543
  }
1544
+ flushSendBuffer();
1426
1545
  }
1427
1546
  function clearInput() {
1428
1547
  mobileInput.value = '';
package/public/index.html CHANGED
@@ -131,6 +131,15 @@
131
131
  <select id="dialog-repo-select" disabled><option value="">Select a repo...</option></select>
132
132
  </div>
133
133
 
134
+ <div class="dialog-field">
135
+ <label for="dialog-branch-input">Branch</label>
136
+ <div class="branch-input-wrapper">
137
+ <input type="text" id="dialog-branch-input" placeholder="Search or create branch..." autocomplete="off" />
138
+ <ul id="dialog-branch-list" class="branch-dropdown" hidden></ul>
139
+ </div>
140
+ <span class="dialog-option-hint">Leave empty for auto-generated name</span>
141
+ </div>
142
+
134
143
  <hr class="dialog-separator" />
135
144
  <div class="dialog-custom-path">
136
145
  <label for="custom-path-input">Or enter a local path:</label>
package/public/style.css CHANGED
@@ -618,6 +618,62 @@ dialog#new-session-dialog h2 {
618
618
  cursor: not-allowed;
619
619
  }
620
620
 
621
+ /* ===== Branch Input ===== */
622
+ .branch-input-wrapper {
623
+ position: relative;
624
+ }
625
+
626
+ .branch-input-wrapper input {
627
+ width: 100%;
628
+ padding: 10px 12px;
629
+ background: var(--bg);
630
+ border: 1px solid var(--border);
631
+ border-radius: 6px;
632
+ color: var(--text);
633
+ font-size: 0.875rem;
634
+ outline: none;
635
+ -webkit-appearance: none;
636
+ }
637
+
638
+ .branch-input-wrapper input:focus {
639
+ border-color: var(--accent);
640
+ }
641
+
642
+ .branch-dropdown {
643
+ position: absolute;
644
+ top: 100%;
645
+ left: 0;
646
+ right: 0;
647
+ max-height: 200px;
648
+ overflow-y: auto;
649
+ background: var(--bg);
650
+ border: 1px solid var(--border);
651
+ border-top: none;
652
+ border-radius: 0 0 6px 6px;
653
+ list-style: none;
654
+ z-index: 10;
655
+ margin: 0;
656
+ padding: 0;
657
+ }
658
+
659
+ .branch-dropdown li {
660
+ padding: 8px 12px;
661
+ font-size: 0.85rem;
662
+ color: var(--text);
663
+ cursor: pointer;
664
+ }
665
+
666
+ .branch-dropdown li:hover,
667
+ .branch-dropdown li.highlighted {
668
+ background: var(--surface);
669
+ color: var(--accent);
670
+ }
671
+
672
+ .branch-dropdown li.branch-create-new {
673
+ color: var(--accent);
674
+ font-style: italic;
675
+ }
676
+
621
677
  .repo-group-label {
622
678
  font-size: 0.75rem;
623
679
  font-weight: 600;