claude-remote-cli 3.9.4 → 3.9.5

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.
@@ -13,7 +13,7 @@ import * as auth from './auth.js';
13
13
  import * as sessions from './sessions.js';
14
14
  import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames, populateMetaCache } from './sessions.js';
15
15
  import { setupWebSocket } from './ws.js';
16
- import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
16
+ import { WorktreeWatcher, BranchWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
17
17
  import { isInstalled as serviceIsInstalled } from './service.js';
18
18
  import { extensionForMime, setClipboardImage } from './clipboard.js';
19
19
  import { listBranches, isBranchStale } from './git.js';
@@ -235,6 +235,26 @@ async function main() {
235
235
  watcher.rebuild(config.workspaces || []);
236
236
  const server = http.createServer(app);
237
237
  const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher, CONFIG_PATH);
238
+ // Watch .git/HEAD files for branch changes and update active sessions
239
+ const branchWatcher = new BranchWatcher((cwdPath, newBranch) => {
240
+ for (const session of sessions.list()) {
241
+ if (session.repoPath === cwdPath || session.cwd === cwdPath) {
242
+ const raw = sessions.get(session.id);
243
+ if (raw) {
244
+ raw.branchName = newBranch;
245
+ broadcastEvent('session-renamed', {
246
+ sessionId: session.id,
247
+ branchName: newBranch,
248
+ displayName: raw.displayName,
249
+ });
250
+ }
251
+ }
252
+ }
253
+ });
254
+ branchWatcher.rebuild(config.workspaces || []);
255
+ watcher.on('worktrees-changed', () => {
256
+ branchWatcher.rebuild(config.workspaces || []);
257
+ });
238
258
  // Configure session defaults for hooks injection
239
259
  sessions.configure({ port: config.port, forceOutputParser: config.forceOutputParser ?? false });
240
260
  // Mount hooks router BEFORE auth middleware — hook callbacks come from localhost Claude Code
@@ -301,9 +321,43 @@ async function main() {
301
321
  });
302
322
  res.json({ ok: true });
303
323
  });
304
- // GET /sessions
305
- app.get('/sessions', requireAuth, (_req, res) => {
306
- res.json(sessions.list());
324
+ // GET /sessions — enrich with live branch from git (rate-limited to avoid spawning git on every poll)
325
+ const branchRefreshCache = new Map(); // sessionId -> last refresh timestamp
326
+ const BRANCH_REFRESH_INTERVAL_MS = 10_000;
327
+ app.get('/sessions', requireAuth, async (_req, res) => {
328
+ const allSessions = sessions.list();
329
+ const now = Date.now();
330
+ // Prune cache entries for sessions that no longer exist
331
+ const activeIds = new Set(allSessions.map((s) => s.id));
332
+ for (const sessionId of branchRefreshCache.keys()) {
333
+ if (!activeIds.has(sessionId))
334
+ branchRefreshCache.delete(sessionId);
335
+ }
336
+ await Promise.all(allSessions.map(async (s) => {
337
+ if (s.type !== 'repo' && s.type !== 'worktree')
338
+ return;
339
+ if (!s.repoPath)
340
+ return;
341
+ const lastRefresh = branchRefreshCache.get(s.id) ?? 0;
342
+ if (now - lastRefresh < BRANCH_REFRESH_INTERVAL_MS)
343
+ return;
344
+ const cwd = s.type === 'repo' ? s.repoPath : s.cwd;
345
+ if (!cwd)
346
+ return;
347
+ branchRefreshCache.set(s.id, now);
348
+ try {
349
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
350
+ const liveBranch = stdout.trim();
351
+ if (liveBranch && liveBranch !== s.branchName) {
352
+ s.branchName = liveBranch;
353
+ const raw = sessions.get(s.id);
354
+ if (raw)
355
+ raw.branchName = liveBranch;
356
+ }
357
+ }
358
+ catch { /* non-fatal */ }
359
+ }));
360
+ res.json(allSessions);
307
361
  });
308
362
  // GET /repos — scan root dirs for repos
309
363
  app.get('/repos', requireAuth, async (_req, res) => {
@@ -947,6 +1001,7 @@ async function main() {
947
1001
  }
948
1002
  function gracefulShutdown() {
949
1003
  closeAnalytics();
1004
+ branchWatcher.close();
950
1005
  server.close();
951
1006
  // Serialize sessions to disk BEFORE killing them
952
1007
  const configDir = path.dirname(CONFIG_PATH);
@@ -1,6 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
3
5
  import { EventEmitter } from 'node:events';
6
+ const execFileAsync = promisify(execFile);
4
7
  export const WORKTREE_DIRS = ['.worktrees', '.claude/worktrees'];
5
8
  export function isValidWorktreePath(worktreePath) {
6
9
  const resolved = path.resolve(worktreePath);
@@ -137,3 +140,124 @@ export class WorktreeWatcher extends EventEmitter {
137
140
  this._closeAll();
138
141
  }
139
142
  }
143
+ export class BranchWatcher {
144
+ _watchers = [];
145
+ _debounceTimers = new Map();
146
+ _lastBranch = new Map();
147
+ _callback;
148
+ constructor(callback) {
149
+ this._callback = callback;
150
+ }
151
+ rebuild(rootDirs) {
152
+ this._closeAll();
153
+ for (const rootDir of rootDirs) {
154
+ let entries;
155
+ try {
156
+ entries = fs.readdirSync(rootDir, { withFileTypes: true });
157
+ }
158
+ catch (_) {
159
+ continue;
160
+ }
161
+ for (const entry of entries) {
162
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
163
+ continue;
164
+ const repoPath = path.join(rootDir, entry.name);
165
+ if (!fs.existsSync(path.join(repoPath, '.git')))
166
+ continue;
167
+ this._watchRepoHeads(repoPath);
168
+ }
169
+ }
170
+ }
171
+ _watchRepoHeads(repoPath) {
172
+ // Watch main repo HEAD
173
+ const mainHead = path.join(repoPath, '.git', 'HEAD');
174
+ this._watchHeadFile(mainHead, repoPath);
175
+ // Watch worktree HEADs: <repoPath>/.git/worktrees/*/HEAD
176
+ const worktreesGitDir = path.join(repoPath, '.git', 'worktrees');
177
+ let wtEntries;
178
+ try {
179
+ wtEntries = fs.readdirSync(worktreesGitDir, { withFileTypes: true });
180
+ }
181
+ catch (_) {
182
+ return; // No worktrees
183
+ }
184
+ for (const entry of wtEntries) {
185
+ if (!entry.isDirectory())
186
+ continue;
187
+ const wtGitDir = path.join(worktreesGitDir, entry.name);
188
+ const headFile = path.join(wtGitDir, 'HEAD');
189
+ if (!fs.existsSync(headFile))
190
+ continue;
191
+ // Map worktree git dir back to checkout path via gitdir file
192
+ const gitdirFile = path.join(wtGitDir, 'gitdir');
193
+ let checkoutPath;
194
+ try {
195
+ const gitdirContent = fs.readFileSync(gitdirFile, 'utf-8').trim();
196
+ // gitdir contains <checkoutPath>/.git — strip the /.git suffix
197
+ checkoutPath = gitdirContent.replace(/\/\.git\/?$/, '');
198
+ }
199
+ catch (_) {
200
+ continue;
201
+ }
202
+ this._watchHeadFile(headFile, checkoutPath);
203
+ }
204
+ }
205
+ _watchHeadFile(headPath, cwdPath) {
206
+ // Seed initial branch to avoid false-positive on first change detection
207
+ try {
208
+ const content = fs.readFileSync(headPath, 'utf-8').trim();
209
+ const match = content.match(/^ref: refs\/heads\/(.+)$/);
210
+ if (match)
211
+ this._lastBranch.set(cwdPath, match[1]);
212
+ }
213
+ catch (_) { }
214
+ try {
215
+ const watcher = fs.watch(headPath, { persistent: false }, () => {
216
+ this._debouncedCheck(cwdPath);
217
+ });
218
+ watcher.on('error', () => { });
219
+ this._watchers.push(watcher);
220
+ }
221
+ catch (_) { }
222
+ }
223
+ _debouncedCheck(cwdPath) {
224
+ const existing = this._debounceTimers.get(cwdPath);
225
+ if (existing)
226
+ clearTimeout(existing);
227
+ this._debounceTimers.set(cwdPath, setTimeout(() => {
228
+ this._debounceTimers.delete(cwdPath);
229
+ this._readAndEmit(cwdPath);
230
+ }, 300));
231
+ }
232
+ async _readAndEmit(cwdPath) {
233
+ try {
234
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: cwdPath });
235
+ const newBranch = stdout.trim();
236
+ const lastBranch = this._lastBranch.get(cwdPath);
237
+ if (newBranch && newBranch !== lastBranch) {
238
+ this._lastBranch.set(cwdPath, newBranch);
239
+ this._callback(cwdPath, newBranch);
240
+ }
241
+ }
242
+ catch (_) {
243
+ // Non-fatal — repo may be in detached HEAD or mid-rebase
244
+ }
245
+ }
246
+ _closeAll() {
247
+ for (const w of this._watchers) {
248
+ try {
249
+ w.close();
250
+ }
251
+ catch (_) { }
252
+ }
253
+ this._watchers = [];
254
+ for (const timer of this._debounceTimers.values()) {
255
+ clearTimeout(timer);
256
+ }
257
+ this._debounceTimers.clear();
258
+ this._lastBranch.clear();
259
+ }
260
+ close() {
261
+ this._closeAll();
262
+ }
263
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { BranchWatcher } from '../server/watcher.js';
8
+ function makeTempGitRepo() {
9
+ // Resolve symlinks (macOS /var → /private/var) so paths match git output
10
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'branch-watcher-test-')));
11
+ execFileSync('git', ['init', '-b', 'main'], { cwd: dir });
12
+ execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@test.com', 'commit', '--allow-empty', '-m', 'init'], { cwd: dir });
13
+ return dir;
14
+ }
15
+ describe('BranchWatcher', () => {
16
+ const cleanups = [];
17
+ afterEach(() => {
18
+ for (const fn of cleanups) {
19
+ try {
20
+ fn();
21
+ }
22
+ catch { /* ignore */ }
23
+ }
24
+ cleanups.length = 0;
25
+ });
26
+ it('detects branch change via HEAD file write', async () => {
27
+ const repoDir = makeTempGitRepo();
28
+ const parentDir = path.dirname(repoDir);
29
+ cleanups.push(() => fs.rmSync(repoDir, { recursive: true, force: true }));
30
+ const events = [];
31
+ const watcher = new BranchWatcher((cwdPath, newBranch) => {
32
+ events.push({ cwdPath, newBranch });
33
+ });
34
+ cleanups.push(() => watcher.close());
35
+ watcher.rebuild([parentDir]);
36
+ // Let fs.watch initialize
37
+ await new Promise(resolve => setTimeout(resolve, 200));
38
+ // Create the branch first, then simulate checkout by writing HEAD directly
39
+ // (more deterministic than git checkout which uses lock+rename)
40
+ execFileSync('git', ['branch', 'feature-test'], { cwd: repoDir });
41
+ const headPath = path.join(repoDir, '.git', 'HEAD');
42
+ fs.writeFileSync(headPath, 'ref: refs/heads/feature-test\n');
43
+ // Wait for debounce (300ms) + processing
44
+ await new Promise(resolve => setTimeout(resolve, 800));
45
+ assert.ok(events.length > 0, 'Expected at least one branch change event');
46
+ const lastEvent = events[events.length - 1];
47
+ assert.equal(lastEvent.cwdPath, repoDir);
48
+ assert.equal(lastEvent.newBranch, 'feature-test');
49
+ });
50
+ it('does not fire callback if branch did not change', async () => {
51
+ const repoDir = makeTempGitRepo();
52
+ const parentDir = path.dirname(repoDir);
53
+ cleanups.push(() => fs.rmSync(repoDir, { recursive: true, force: true }));
54
+ const events = [];
55
+ const watcher = new BranchWatcher((cwdPath, newBranch) => {
56
+ events.push({ cwdPath, newBranch });
57
+ });
58
+ cleanups.push(() => watcher.close());
59
+ watcher.rebuild([parentDir]);
60
+ await new Promise(resolve => setTimeout(resolve, 200));
61
+ // Touch the HEAD file without changing the branch content
62
+ const headPath = path.join(repoDir, '.git', 'HEAD');
63
+ const content = fs.readFileSync(headPath, 'utf-8');
64
+ fs.writeFileSync(headPath, content);
65
+ await new Promise(resolve => setTimeout(resolve, 800));
66
+ assert.equal(events.length, 0, 'Should not fire callback when branch is unchanged');
67
+ });
68
+ it('closes cleanly', () => {
69
+ const watcher = new BranchWatcher(() => { });
70
+ watcher.close();
71
+ // No error means success
72
+ });
73
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "3.9.4",
3
+ "version": "3.9.5",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",