claude-remote-cli 2.4.5 → 2.4.7

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,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-capable" content="yes" />
12
12
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
13
13
  <meta name="theme-color" content="#1a1a1a" />
14
- <script type="module" crossorigin src="/assets/index-DOfplZTg.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-D_vlhnTU.css">
14
+ <script type="module" crossorigin src="/assets/index-DHmjTDUE.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-0GsaVTCt.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="app"></div>
@@ -12,7 +12,7 @@ import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, deleteMeta, ensu
12
12
  import * as auth from './auth.js';
13
13
  import * as sessions from './sessions.js';
14
14
  import { setupWebSocket } from './ws.js';
15
- import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath } from './watcher.js';
15
+ import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain } from './watcher.js';
16
16
  import { isInstalled as serviceIsInstalled } from './service.js';
17
17
  import { extensionForMime, setClipboardImage } from './clipboard.js';
18
18
  const __filename = fileURLToPath(import.meta.url);
@@ -384,7 +384,7 @@ async function main() {
384
384
  res.json(response);
385
385
  });
386
386
  // GET /worktrees?repo=<path> — list worktrees; omit repo to scan all repos in all rootDirs
387
- app.get('/worktrees', requireAuth, (req, res) => {
387
+ app.get('/worktrees', requireAuth, async (req, res) => {
388
388
  const repoParam = typeof req.query.repo === 'string' ? req.query.repo : undefined;
389
389
  const roots = config.rootDirs || [];
390
390
  const worktrees = [];
@@ -397,31 +397,54 @@ async function main() {
397
397
  reposToScan = scanAllRepos(roots);
398
398
  }
399
399
  for (const repo of reposToScan) {
400
- for (const dir of WORKTREE_DIRS) {
401
- const worktreeDir = path.join(repo.path, dir);
402
- let entries;
403
- try {
404
- entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
405
- }
406
- catch (_) {
407
- continue;
408
- }
409
- for (const entry of entries) {
410
- if (!entry.isDirectory())
411
- continue;
412
- const wtPath = path.join(worktreeDir, entry.name);
413
- const meta = readMeta(CONFIG_PATH, wtPath);
400
+ // Use git worktree list to discover all worktrees (including those at arbitrary paths)
401
+ try {
402
+ const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd: repo.path });
403
+ const parsed = parseWorktreeListPorcelain(stdout, repo.path);
404
+ for (const wt of parsed) {
405
+ const dirName = wt.path.split('/').pop() || '';
406
+ const meta = readMeta(CONFIG_PATH, wt.path);
414
407
  worktrees.push({
415
- name: entry.name,
416
- path: wtPath,
408
+ name: dirName,
409
+ path: wt.path,
417
410
  repoName: repo.name,
418
411
  repoPath: repo.path,
419
412
  root: repo.root,
420
- displayName: meta ? meta.displayName : '',
421
- lastActivity: meta ? meta.lastActivity : '',
413
+ displayName: meta?.displayName || wt.branch || dirName,
414
+ lastActivity: meta?.lastActivity || '',
415
+ branchName: wt.branch || meta?.branchName || dirName,
422
416
  });
423
417
  }
424
418
  }
419
+ catch {
420
+ // git worktree list failed — fall back to directory scanning
421
+ for (const dir of WORKTREE_DIRS) {
422
+ const worktreeDir = path.join(repo.path, dir);
423
+ let entries;
424
+ try {
425
+ entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
426
+ }
427
+ catch (_) {
428
+ continue;
429
+ }
430
+ for (const entry of entries) {
431
+ if (!entry.isDirectory())
432
+ continue;
433
+ const wtPath = path.join(worktreeDir, entry.name);
434
+ const meta = readMeta(CONFIG_PATH, wtPath);
435
+ worktrees.push({
436
+ name: entry.name,
437
+ path: wtPath,
438
+ repoName: repo.name,
439
+ repoPath: repo.path,
440
+ root: repo.root,
441
+ displayName: meta?.displayName || '',
442
+ lastActivity: meta?.lastActivity || '',
443
+ branchName: meta?.branchName || entry.name,
444
+ });
445
+ }
446
+ }
447
+ }
425
448
  }
426
449
  res.json(worktrees);
427
450
  });
@@ -555,13 +578,15 @@ async function main() {
555
578
  dirName = 'mobile-' + name + '-' + Date.now().toString(36);
556
579
  resolvedBranch = dirName;
557
580
  }
558
- const worktreeDir = path.join(repoPath, '.worktrees');
581
+ const worktreeDir = path.join(repoPath, WORKTREE_DIRS[0]);
559
582
  let targetDir = path.join(worktreeDir, dirName);
560
583
  if (fs.existsSync(targetDir)) {
561
584
  targetDir = targetDir + '-' + Date.now().toString(36);
562
585
  dirName = path.basename(targetDir);
563
586
  }
564
- ensureGitignore(repoPath, '.worktrees/');
587
+ for (const dir of WORKTREE_DIRS) {
588
+ ensureGitignore(repoPath, dir + '/');
589
+ }
565
590
  try {
566
591
  // Check if branch exists locally or on a remote
567
592
  let branchExists = false;
@@ -605,6 +630,7 @@ async function main() {
605
630
  cwd,
606
631
  root,
607
632
  worktreeName,
633
+ branchName: branchName || worktreeName,
608
634
  displayName,
609
635
  command: config.claudeCommand,
610
636
  args,
@@ -11,7 +11,7 @@ let idleChangeCallback = null;
11
11
  function onIdleChange(cb) {
12
12
  idleChangeCallback = cb;
13
13
  }
14
- function create({ type, repoName, repoPath, cwd, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
14
+ function create({ type, repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
15
15
  const id = crypto.randomBytes(8).toString('hex');
16
16
  const createdAt = new Date().toISOString();
17
17
  // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
@@ -35,6 +35,7 @@ function create({ type, repoName, repoPath, cwd, root, worktreeName, displayName
35
35
  repoName: repoName || '',
36
36
  repoPath,
37
37
  worktreeName: worktreeName || '',
38
+ branchName: branchName || worktreeName || '',
38
39
  displayName: displayName || worktreeName || repoName || '',
39
40
  pty: ptyProcess,
40
41
  createdAt,
@@ -97,20 +98,21 @@ function create({ type, repoName, repoPath, cwd, root, worktreeName, displayName
97
98
  const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
98
99
  fs.rm(tmpDir, { recursive: true, force: true }, () => { });
99
100
  });
100
- return { id, type: session.type, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false };
101
+ return { id, type: session.type, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, branchName: session.branchName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false };
101
102
  }
102
103
  function get(id) {
103
104
  return sessions.get(id);
104
105
  }
105
106
  function list() {
106
107
  return Array.from(sessions.values())
107
- .map(({ id, type, root, repoName, repoPath, worktreeName, displayName, createdAt, lastActivity, idle }) => ({
108
+ .map(({ id, type, root, repoName, repoPath, worktreeName, branchName, displayName, createdAt, lastActivity, idle }) => ({
108
109
  id,
109
110
  type,
110
111
  root,
111
112
  repoName,
112
113
  repoPath,
113
114
  worktreeName,
115
+ branchName,
114
116
  displayName,
115
117
  createdAt,
116
118
  lastActivity,
@@ -8,6 +8,33 @@ export function isValidWorktreePath(worktreePath) {
8
8
  return resolved.includes(path.sep + dir + path.sep);
9
9
  });
10
10
  }
11
+ /**
12
+ * Parse `git worktree list --porcelain` output into structured entries.
13
+ * Skips the main worktree (matching repoPath) and bare/detached entries.
14
+ */
15
+ export function parseWorktreeListPorcelain(stdout, repoPath) {
16
+ const results = [];
17
+ const blocks = stdout.split('\n\n').filter(Boolean);
18
+ for (const block of blocks) {
19
+ const lines = block.split('\n');
20
+ let wtPath = '';
21
+ let branch = '';
22
+ let bare = false;
23
+ for (const line of lines) {
24
+ if (line.startsWith('worktree '))
25
+ wtPath = line.slice(9);
26
+ if (line.startsWith('branch refs/heads/'))
27
+ branch = line.slice(18);
28
+ if (line === 'bare')
29
+ bare = true;
30
+ }
31
+ // Skip the main worktree (repo root), bare repos, and detached HEAD
32
+ if (!wtPath || wtPath === repoPath || bare || !branch)
33
+ continue;
34
+ results.push({ path: wtPath, branch });
35
+ }
36
+ return results;
37
+ }
11
38
  export class WorktreeWatcher extends EventEmitter {
12
39
  _watchers;
13
40
  _debounceTimer;
@@ -214,4 +214,54 @@ describe('sessions', () => {
214
214
  const found = sessions.findRepoSession('/tmp/my-repo');
215
215
  assert.strictEqual(found, undefined, 'should not match worktree sessions');
216
216
  });
217
+ it('branchName defaults to worktreeName when not specified', () => {
218
+ const result = sessions.create({
219
+ repoName: 'test-repo',
220
+ repoPath: '/tmp',
221
+ worktreeName: 'dy-feat-my-feature',
222
+ command: '/bin/echo',
223
+ args: ['hello'],
224
+ });
225
+ createdIds.push(result.id);
226
+ assert.strictEqual(result.branchName, 'dy-feat-my-feature');
227
+ });
228
+ it('branchName is set independently from worktreeName', () => {
229
+ const result = sessions.create({
230
+ repoName: 'test-repo',
231
+ repoPath: '/tmp',
232
+ worktreeName: 'dy-feat-my-feature',
233
+ branchName: 'dy/feat/my-feature',
234
+ command: '/bin/echo',
235
+ args: ['hello'],
236
+ });
237
+ createdIds.push(result.id);
238
+ assert.strictEqual(result.worktreeName, 'dy-feat-my-feature');
239
+ assert.strictEqual(result.branchName, 'dy/feat/my-feature');
240
+ });
241
+ it('list includes branchName field', () => {
242
+ const result = sessions.create({
243
+ repoName: 'test-repo',
244
+ repoPath: '/tmp',
245
+ worktreeName: 'my-wt',
246
+ branchName: 'feat/my-branch',
247
+ command: '/bin/echo',
248
+ args: ['hello'],
249
+ });
250
+ createdIds.push(result.id);
251
+ const list = sessions.list();
252
+ const session = list.find(s => s.id === result.id);
253
+ assert.ok(session);
254
+ assert.strictEqual(session.branchName, 'feat/my-branch');
255
+ });
256
+ it('branchName defaults to empty string when neither branchName nor worktreeName provided', () => {
257
+ const result = sessions.create({
258
+ type: 'repo',
259
+ repoName: 'test-repo',
260
+ repoPath: '/tmp',
261
+ command: '/bin/echo',
262
+ args: ['hello'],
263
+ });
264
+ createdIds.push(result.id);
265
+ assert.strictEqual(result.branchName, '');
266
+ });
217
267
  });
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { WORKTREE_DIRS, isValidWorktreePath } from '../server/watcher.js';
3
+ import { WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain } from '../server/watcher.js';
4
4
  describe('worktree directories constant', () => {
5
5
  it('should include both .worktrees and .claude/worktrees', () => {
6
6
  assert.deepEqual(WORKTREE_DIRS, ['.worktrees', '.claude/worktrees']);
@@ -32,6 +32,120 @@ describe('branch name to directory name', () => {
32
32
  assert.equal(dirName, 'my-feature');
33
33
  });
34
34
  });
35
+ describe('parseWorktreeListPorcelain', () => {
36
+ const repoPath = '/Users/me/code/my-repo';
37
+ it('should parse a single worktree entry', () => {
38
+ const stdout = [
39
+ `worktree ${repoPath}`,
40
+ 'HEAD abc123',
41
+ 'branch refs/heads/main',
42
+ '',
43
+ 'worktree /Users/me/code/my-repo/.worktrees/feat-branch',
44
+ 'HEAD def456',
45
+ 'branch refs/heads/feat/branch',
46
+ '',
47
+ ].join('\n');
48
+ const result = parseWorktreeListPorcelain(stdout, repoPath);
49
+ assert.equal(result.length, 1);
50
+ assert.equal(result[0].path, '/Users/me/code/my-repo/.worktrees/feat-branch');
51
+ assert.equal(result[0].branch, 'feat/branch');
52
+ });
53
+ it('should parse multiple worktree entries', () => {
54
+ const stdout = [
55
+ `worktree ${repoPath}`,
56
+ 'HEAD abc123',
57
+ 'branch refs/heads/main',
58
+ '',
59
+ 'worktree /Users/me/code/my-repo/.worktrees/feat-a',
60
+ 'HEAD def456',
61
+ 'branch refs/heads/feat/a',
62
+ '',
63
+ 'worktree /Users/me/other-path/extend-cli',
64
+ 'HEAD 789abc',
65
+ 'branch refs/heads/dy/feat/worktree-isolation',
66
+ '',
67
+ ].join('\n');
68
+ const result = parseWorktreeListPorcelain(stdout, repoPath);
69
+ assert.equal(result.length, 2);
70
+ assert.equal(result[0].path, '/Users/me/code/my-repo/.worktrees/feat-a');
71
+ assert.equal(result[0].branch, 'feat/a');
72
+ assert.equal(result[1].path, '/Users/me/other-path/extend-cli');
73
+ assert.equal(result[1].branch, 'dy/feat/worktree-isolation');
74
+ });
75
+ it('should skip the main worktree (repo root)', () => {
76
+ const stdout = [
77
+ `worktree ${repoPath}`,
78
+ 'HEAD abc123',
79
+ 'branch refs/heads/main',
80
+ '',
81
+ ].join('\n');
82
+ const result = parseWorktreeListPorcelain(stdout, repoPath);
83
+ assert.equal(result.length, 0);
84
+ });
85
+ it('should skip bare entries', () => {
86
+ const stdout = [
87
+ `worktree ${repoPath}`,
88
+ 'HEAD abc123',
89
+ 'branch refs/heads/main',
90
+ '',
91
+ 'worktree /some/bare/repo',
92
+ 'HEAD def456',
93
+ 'bare',
94
+ '',
95
+ ].join('\n');
96
+ const result = parseWorktreeListPorcelain(stdout, repoPath);
97
+ assert.equal(result.length, 0);
98
+ });
99
+ it('should skip detached HEAD worktrees (no branch line)', () => {
100
+ const stdout = [
101
+ `worktree ${repoPath}`,
102
+ 'HEAD abc123',
103
+ 'branch refs/heads/main',
104
+ '',
105
+ 'worktree /Users/me/code/my-repo/.worktrees/detached',
106
+ 'HEAD def456',
107
+ 'detached',
108
+ '',
109
+ ].join('\n');
110
+ const result = parseWorktreeListPorcelain(stdout, repoPath);
111
+ assert.equal(result.length, 0);
112
+ });
113
+ it('should handle empty output', () => {
114
+ const result = parseWorktreeListPorcelain('', repoPath);
115
+ assert.equal(result.length, 0);
116
+ });
117
+ it('should discover worktrees at arbitrary paths outside .worktrees/', () => {
118
+ const stdout = [
119
+ `worktree ${repoPath}`,
120
+ 'HEAD abc123',
121
+ 'branch refs/heads/main',
122
+ '',
123
+ 'worktree /completely/different/path/project-checkout',
124
+ 'HEAD def456',
125
+ 'branch refs/heads/feature/my-feature',
126
+ '',
127
+ ].join('\n');
128
+ const result = parseWorktreeListPorcelain(stdout, repoPath);
129
+ assert.equal(result.length, 1);
130
+ assert.equal(result[0].path, '/completely/different/path/project-checkout');
131
+ assert.equal(result[0].branch, 'feature/my-feature');
132
+ });
133
+ it('should handle deeply nested branch names', () => {
134
+ const stdout = [
135
+ `worktree ${repoPath}`,
136
+ 'HEAD abc123',
137
+ 'branch refs/heads/main',
138
+ '',
139
+ 'worktree /Users/me/code/my-repo/.worktrees/dy-feat-deep-nesting',
140
+ 'HEAD def456',
141
+ 'branch refs/heads/dy/feat/deep/nesting/here',
142
+ '',
143
+ ].join('\n');
144
+ const result = parseWorktreeListPorcelain(stdout, repoPath);
145
+ assert.equal(result.length, 1);
146
+ assert.equal(result[0].branch, 'dy/feat/deep/nesting/here');
147
+ });
148
+ });
35
149
  describe('CLI worktree arg parsing', () => {
36
150
  it('should extract --yolo and leave other args intact', () => {
37
151
  const args = ['add', './.worktrees/my-feature', '-b', 'my-feature', '--yolo'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "2.4.5",
3
+ "version": "2.4.7",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",