claude-remote-cli 3.0.2 → 3.0.4

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,16 +11,27 @@ import cookieParser from 'cookie-parser';
11
11
  import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, deleteMeta, ensureMetaDir } from './config.js';
12
12
  import * as auth from './auth.js';
13
13
  import * as sessions from './sessions.js';
14
- import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames, startSdkIdleSweep, stopSdkIdleSweep } from './sessions.js';
14
+ import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames } from './sessions.js';
15
15
  import { setupWebSocket } from './ws.js';
16
16
  import { WorktreeWatcher, 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 } from './git.js';
20
20
  import * as push from './push.js';
21
+ import { createWorkspaceRouter } from './workspaces.js';
21
22
  const __filename = fileURLToPath(import.meta.url);
22
23
  const __dirname = path.dirname(__filename);
23
24
  const execFileAsync = promisify(execFile);
25
+ // ── Signal protection ────────────────────────────────────────────────────
26
+ // Ignore SIGPIPE: piped bash commands (e.g. `cmd | grep | tail`) generate
27
+ // SIGPIPE when the reading end of the pipe closes before the writer finishes.
28
+ // node-pty's native module can propagate these to PTY sessions, causing
29
+ // unexpected "session exited" in the browser. Ignoring SIGPIPE at the server
30
+ // level prevents this cascade.
31
+ process.on('SIGPIPE', () => { });
32
+ // Ignore SIGHUP: if the controlling terminal disconnects (e.g. SSH drops),
33
+ // keep the server and all PTY sessions alive.
34
+ process.on('SIGHUP', () => { });
24
35
  // When run via CLI bin, config lives in ~/.config/claude-remote-cli/
25
36
  // When run directly (development), fall back to local config.json
26
37
  const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', '..', 'config.json');
@@ -90,40 +101,6 @@ function promptPin(question) {
90
101
  });
91
102
  });
92
103
  }
93
- function scanReposInRoot(rootDir) {
94
- const repos = [];
95
- let entries;
96
- try {
97
- entries = fs.readdirSync(rootDir, { withFileTypes: true });
98
- }
99
- catch (_) {
100
- return repos;
101
- }
102
- for (const entry of entries) {
103
- if (!entry.isDirectory() || entry.name.startsWith('.'))
104
- continue;
105
- const fullPath = path.join(rootDir, entry.name);
106
- const dotGit = path.join(fullPath, '.git');
107
- try {
108
- // Only count directories with a .git *directory* as repos.
109
- // Worktrees and submodules have a .git *file* and should be skipped.
110
- if (fs.statSync(dotGit).isDirectory()) {
111
- repos.push({ name: entry.name, path: fullPath, root: rootDir });
112
- }
113
- }
114
- catch (_) {
115
- // .git doesn't exist — not a repo
116
- }
117
- }
118
- return repos;
119
- }
120
- function scanAllRepos(rootDirs) {
121
- const repos = [];
122
- for (const rootDir of rootDirs) {
123
- repos.push(...scanReposInRoot(rootDir));
124
- }
125
- return repos;
126
- }
127
104
  function ensureGitignore(repoPath, entry) {
128
105
  const gitignorePath = path.join(repoPath, '.gitignore');
129
106
  try {
@@ -156,12 +133,6 @@ async function main() {
156
133
  config.port = parseInt(process.env.CLAUDE_REMOTE_PORT, 10);
157
134
  if (process.env.CLAUDE_REMOTE_HOST)
158
135
  config.host = process.env.CLAUDE_REMOTE_HOST;
159
- // Enable SDK debug logging if requested
160
- if (process.env.CLAUDE_REMOTE_DEBUG_LOG === '1' || config.debugLog) {
161
- const { enableDebugLog } = await import('./sdk-handler.js');
162
- enableDebugLog(true);
163
- console.log('SDK debug logging enabled → ~/.config/claude-remote-cli/debug/');
164
- }
165
136
  push.ensureVapidKeys(config, CONFIG_PATH, saveConfig);
166
137
  if (!config.pinHash) {
167
138
  const pin = await promptPin('Set up a PIN for claude-remote-cli:');
@@ -226,9 +197,12 @@ async function main() {
226
197
  });
227
198
  }
228
199
  const watcher = new WorktreeWatcher();
229
- watcher.rebuild(config.rootDirs || []);
200
+ watcher.rebuild(config.workspaces || []);
230
201
  const server = http.createServer(app);
231
202
  const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher);
203
+ // Mount workspace router
204
+ const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
205
+ app.use('/workspaces', requireAuth, workspaceRouter);
232
206
  // Restore sessions from a previous update restart
233
207
  const configDir = path.dirname(CONFIG_PATH);
234
208
  const restoredCount = await restoreFromDisk(configDir);
@@ -278,19 +252,6 @@ async function main() {
278
252
  app.get('/sessions', requireAuth, (_req, res) => {
279
253
  res.json(sessions.list());
280
254
  });
281
- // GET /repos — scan root dirs for repos
282
- app.get('/repos', requireAuth, (_req, res) => {
283
- const repos = scanAllRepos(config.rootDirs || []);
284
- // Also include legacy manually-added repos
285
- if (config.repos) {
286
- for (const repo of config.repos) {
287
- if (!repos.some((r) => r.path === repo.path)) {
288
- repos.push(repo);
289
- }
290
- }
291
- }
292
- res.json(repos);
293
- });
294
255
  // GET /branches?repo=<path> — list local and remote branches for a repo
295
256
  app.get('/branches', requireAuth, async (req, res) => {
296
257
  const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
@@ -301,134 +262,6 @@ async function main() {
301
262
  }
302
263
  res.json(await listBranches(repoPath, { refresh }));
303
264
  });
304
- // GET /git-status?repo=<path>&branch=<name>
305
- app.get('/git-status', requireAuth, async (req, res) => {
306
- const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
307
- const branch = typeof req.query.branch === 'string' ? req.query.branch : undefined;
308
- if (!repoPath || !branch) {
309
- res.status(400).json({ error: 'repo and branch query parameters are required' });
310
- return;
311
- }
312
- let prState = null;
313
- let additions = 0;
314
- let deletions = 0;
315
- // Try gh CLI for PR status
316
- try {
317
- const { stdout } = await execFileAsync('gh', [
318
- 'pr', 'view', branch,
319
- '--json', 'state,additions,deletions',
320
- ], { cwd: repoPath });
321
- const data = JSON.parse(stdout);
322
- if (data.state)
323
- prState = data.state.toLowerCase();
324
- if (typeof data.additions === 'number')
325
- additions = data.additions;
326
- if (typeof data.deletions === 'number')
327
- deletions = data.deletions;
328
- }
329
- catch {
330
- // No PR or gh not available — fall back to git diff against default branch
331
- try {
332
- // Detect default branch (main, master, etc.)
333
- let baseBranch = 'main';
334
- try {
335
- const { stdout: headRef } = await execFileAsync('git', [
336
- 'symbolic-ref', 'refs/remotes/origin/HEAD', '--short',
337
- ], { cwd: repoPath });
338
- baseBranch = headRef.trim().replace(/^origin\//, '');
339
- }
340
- catch { /* use main as fallback */ }
341
- const { stdout } = await execFileAsync('git', [
342
- 'diff', '--shortstat', baseBranch + '...' + branch,
343
- ], { cwd: repoPath });
344
- const addMatch = stdout.match(/(\d+) insertion/);
345
- const delMatch = stdout.match(/(\d+) deletion/);
346
- if (addMatch)
347
- additions = parseInt(addMatch[1], 10);
348
- if (delMatch)
349
- deletions = parseInt(delMatch[1], 10);
350
- }
351
- catch { /* no diff data */ }
352
- }
353
- res.json({ prState, additions, deletions });
354
- });
355
- // GET /pull-requests?repo=<path>
356
- app.get('/pull-requests', requireAuth, async (req, res) => {
357
- const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
358
- if (!repoPath) {
359
- res.status(400).json({ prs: [], error: 'repo query parameter is required' });
360
- return;
361
- }
362
- const fields = 'number,title,url,headRefName,state,author,updatedAt,additions,deletions,reviewDecision';
363
- // Get current GitHub user
364
- let currentUser = '';
365
- try {
366
- const { stdout: whoami } = await execFileAsync('gh', ['api', 'user', '--jq', '.login'], { cwd: repoPath });
367
- currentUser = whoami.trim();
368
- }
369
- catch {
370
- const response = { prs: [], error: 'gh_not_authenticated' };
371
- res.json(response);
372
- return;
373
- }
374
- // Fetch authored PRs
375
- const authored = [];
376
- try {
377
- const { stdout } = await execFileAsync('gh', [
378
- 'pr', 'list', '--author', currentUser, '--state', 'open', '--limit', '30',
379
- '--json', fields,
380
- ], { cwd: repoPath });
381
- const raw = JSON.parse(stdout);
382
- for (const pr of raw) {
383
- authored.push({
384
- number: pr.number,
385
- title: pr.title,
386
- url: pr.url,
387
- headRefName: pr.headRefName,
388
- state: pr.state,
389
- author: pr.author?.login ?? currentUser,
390
- role: 'author',
391
- updatedAt: pr.updatedAt,
392
- additions: pr.additions ?? 0,
393
- deletions: pr.deletions ?? 0,
394
- reviewDecision: pr.reviewDecision ?? null,
395
- });
396
- }
397
- }
398
- catch { /* no authored PRs or gh error */ }
399
- // Fetch review-requested PRs
400
- const reviewing = [];
401
- try {
402
- const { stdout } = await execFileAsync('gh', [
403
- 'pr', 'list', '--search', `review-requested:${currentUser}`, '--state', 'open', '--limit', '30',
404
- '--json', fields,
405
- ], { cwd: repoPath });
406
- const raw = JSON.parse(stdout);
407
- for (const pr of raw) {
408
- reviewing.push({
409
- number: pr.number,
410
- title: pr.title,
411
- url: pr.url,
412
- headRefName: pr.headRefName,
413
- state: pr.state,
414
- author: pr.author?.login ?? '',
415
- role: 'reviewer',
416
- updatedAt: pr.updatedAt,
417
- additions: pr.additions ?? 0,
418
- deletions: pr.deletions ?? 0,
419
- reviewDecision: pr.reviewDecision ?? null,
420
- });
421
- }
422
- }
423
- catch { /* no review-requested PRs or gh error */ }
424
- // Deduplicate: if a PR appears in both (user is author AND reviewer), keep as 'author'
425
- const seen = new Set(authored.map(pr => pr.number));
426
- const combined = [...authored, ...reviewing.filter(pr => !seen.has(pr.number))];
427
- // Sort by updatedAt descending
428
- combined.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
429
- const response = { prs: combined };
430
- res.json(response);
431
- });
432
265
  // GET /worktrees?repo=<path> — list worktrees; omit repo to scan all repos in all rootDirs
433
266
  app.get('/worktrees', requireAuth, async (req, res) => {
434
267
  const repoParam = typeof req.query.repo === 'string' ? req.query.repo : undefined;
@@ -440,7 +273,30 @@ async function main() {
440
273
  reposToScan = [{ path: repoParam, name: repoParam.split('/').filter(Boolean).pop() || '', root }];
441
274
  }
442
275
  else {
443
- reposToScan = scanAllRepos(roots);
276
+ reposToScan = [];
277
+ for (const rootDir of roots) {
278
+ let entries;
279
+ try {
280
+ entries = fs.readdirSync(rootDir, { withFileTypes: true });
281
+ }
282
+ catch (_) {
283
+ continue;
284
+ }
285
+ for (const entry of entries) {
286
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
287
+ continue;
288
+ const fullPath = path.join(rootDir, entry.name);
289
+ const dotGit = path.join(fullPath, '.git');
290
+ try {
291
+ if (fs.statSync(dotGit).isDirectory()) {
292
+ reposToScan.push({ name: entry.name, path: fullPath, root: rootDir });
293
+ }
294
+ }
295
+ catch (_) {
296
+ // .git doesn't exist — not a repo
297
+ }
298
+ }
299
+ }
444
300
  }
445
301
  for (const repo of reposToScan) {
446
302
  // Use git worktree list to discover all worktrees (including those at arbitrary paths)
@@ -502,42 +358,6 @@ async function main() {
502
358
  });
503
359
  res.json(unique);
504
360
  });
505
- // GET /roots — list root directories
506
- app.get('/roots', requireAuth, (_req, res) => {
507
- res.json(config.rootDirs || []);
508
- });
509
- // POST /roots — add a root directory
510
- app.post('/roots', requireAuth, (req, res) => {
511
- const { path: rootPath } = req.body;
512
- if (!rootPath) {
513
- res.status(400).json({ error: 'path is required' });
514
- return;
515
- }
516
- if (!config.rootDirs)
517
- config.rootDirs = [];
518
- if (config.rootDirs.includes(rootPath)) {
519
- res.status(409).json({ error: 'Root already exists' });
520
- return;
521
- }
522
- config.rootDirs.push(rootPath);
523
- saveConfig(CONFIG_PATH, config);
524
- watcher.rebuild(config.rootDirs);
525
- broadcastEvent('worktrees-changed');
526
- res.status(201).json(config.rootDirs);
527
- });
528
- // DELETE /roots — remove a root directory
529
- app.delete('/roots', requireAuth, (req, res) => {
530
- const { path: rootPath } = req.body;
531
- if (!rootPath || !config.rootDirs) {
532
- res.status(400).json({ error: 'path is required' });
533
- return;
534
- }
535
- config.rootDirs = config.rootDirs.filter((r) => r !== rootPath);
536
- saveConfig(CONFIG_PATH, config);
537
- watcher.rebuild(config.rootDirs);
538
- broadcastEvent('worktrees-changed');
539
- res.json(config.rootDirs);
540
- });
541
361
  // GET /config/defaultAgent — get default coding agent
542
362
  app.get('/config/defaultAgent', requireAuth, (_req, res) => {
543
363
  res.json({ defaultAgent: config.defaultAgent || 'claude' });
@@ -612,13 +432,7 @@ async function main() {
612
432
  return;
613
433
  }
614
434
  }
615
- // Check no active session is using this worktree
616
- const activeSessions = sessions.list();
617
- const conflict = activeSessions.find(function (s) { return s.repoPath === worktreePath; });
618
- if (conflict) {
619
- res.status(409).json({ error: 'Close the active session first' });
620
- return;
621
- }
435
+ // Multiple sessions per worktree allowed (multi-tab support)
622
436
  // Derive branch name from metadata or worktree directory name
623
437
  const meta = readMeta(CONFIG_PATH, worktreePath);
624
438
  const branchName = (meta && meta.branchName) || worktreePath.split('/').pop() || '';
@@ -662,7 +476,7 @@ async function main() {
662
476
  });
663
477
  // POST /sessions
664
478
  app.post('/sessions', requireAuth, async (req, res) => {
665
- const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent, useTmux } = req.body;
479
+ const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent, useTmux, needsBranchRename, branchRenamePrompt } = req.body;
666
480
  if (!repoPath) {
667
481
  res.status(400).json({ error: 'repoPath is required' });
668
482
  return;
@@ -683,8 +497,11 @@ async function main() {
683
497
  let sessionRepoPath;
684
498
  let resolvedBranch = '';
685
499
  if (worktreePath) {
686
- // Resume existing worktree
687
- args = [...AGENT_CONTINUE_ARGS[resolvedAgent], ...baseArgs];
500
+ // Only use --continue if:
501
+ // 1. Not a brand-new worktree (needsBranchRename flag)
502
+ // 2. A prior Claude session exists in this directory (.claude/ dir present)
503
+ const hasPriorSession = !needsBranchRename && fs.existsSync(path.join(worktreePath, '.claude'));
504
+ args = hasPriorSession ? [...AGENT_CONTINUE_ARGS[resolvedAgent], ...baseArgs] : [...baseArgs];
688
505
  cwd = worktreePath;
689
506
  sessionRepoPath = worktreePath;
690
507
  worktreeName = worktreePath.split('/').pop() || '';
@@ -816,6 +633,8 @@ async function main() {
816
633
  args,
817
634
  configPath: CONFIG_PATH,
818
635
  useTmux: useTmux ?? config.launchInTmux,
636
+ needsBranchRename: needsBranchRename ?? false,
637
+ branchRenamePrompt: branchRenamePrompt ?? '',
819
638
  });
820
639
  if (!worktreePath) {
821
640
  writeMeta(CONFIG_PATH, {
@@ -835,12 +654,7 @@ async function main() {
835
654
  return;
836
655
  }
837
656
  const resolvedAgent = agent || config.defaultAgent || 'claude';
838
- // One repo session at a time
839
- const existing = sessions.findRepoSession(repoPath);
840
- if (existing) {
841
- res.status(409).json({ error: 'A session already exists for this repo', sessionId: existing.id });
842
- return;
843
- }
657
+ // Multiple sessions per repo allowed (multi-tab support)
844
658
  const name = repoName || repoPath.split('/').filter(Boolean).pop() || 'session';
845
659
  const baseArgs = [
846
660
  ...(config.claudeArgs || []),
@@ -890,58 +704,6 @@ async function main() {
890
704
  res.status(404).json({ error: 'Session not found' });
891
705
  }
892
706
  });
893
- // POST /sessions/:id/message — send message to SDK session
894
- app.post('/sessions/:id/message', requireAuth, (req, res) => {
895
- const id = req.params['id'];
896
- const { text } = req.body;
897
- if (!text) {
898
- res.status(400).json({ error: 'text is required' });
899
- return;
900
- }
901
- const session = sessions.get(id);
902
- if (!session) {
903
- res.status(404).json({ error: 'Session not found' });
904
- return;
905
- }
906
- if (session.mode !== 'sdk') {
907
- res.status(400).json({ error: 'Session is not an SDK session — use WebSocket for PTY sessions' });
908
- return;
909
- }
910
- try {
911
- sessions.write(id, text);
912
- res.json({ ok: true });
913
- }
914
- catch (err) {
915
- const message = err instanceof Error ? err.message : 'Failed to send message';
916
- res.status(500).json({ error: message });
917
- }
918
- });
919
- // POST /sessions/:id/permission — handle permission approval for SDK session
920
- app.post('/sessions/:id/permission', requireAuth, (req, res) => {
921
- const id = req.params['id'];
922
- const { requestId, approved } = req.body;
923
- if (!requestId || typeof approved !== 'boolean') {
924
- res.status(400).json({ error: 'requestId and approved are required' });
925
- return;
926
- }
927
- const session = sessions.get(id);
928
- if (!session) {
929
- res.status(404).json({ error: 'Session not found' });
930
- return;
931
- }
932
- if (session.mode !== 'sdk') {
933
- res.status(400).json({ error: 'Session is not an SDK session' });
934
- return;
935
- }
936
- try {
937
- sessions.handlePermission(id, requestId, approved);
938
- res.json({ ok: true });
939
- }
940
- catch (err) {
941
- const message = err instanceof Error ? err.message : 'Failed to handle permission';
942
- res.status(500).json({ error: message });
943
- }
944
- });
945
707
  // PATCH /sessions/:id — update displayName and persist to metadata
946
708
  app.patch('/sessions/:id', requireAuth, (req, res) => {
947
709
  const { displayName } = req.body;
@@ -1049,15 +811,12 @@ async function main() {
1049
811
  catch {
1050
812
  // tmux not installed or no sessions — ignore
1051
813
  }
1052
- // Start SDK idle sweep
1053
- startSdkIdleSweep();
1054
814
  function gracefulShutdown() {
1055
815
  server.close();
1056
- stopSdkIdleSweep();
1057
816
  // Serialize sessions to disk BEFORE killing them
1058
817
  const configDir = path.dirname(CONFIG_PATH);
1059
818
  serializeAll(configDir);
1060
- // Kill all active sessions (PTY + tmux + SDK)
819
+ // Kill all active sessions (PTY + tmux)
1061
820
  for (const s of sessions.list()) {
1062
821
  try {
1063
822
  sessions.kill(s.id);
@@ -1,7 +1,6 @@
1
1
  import webpush from 'web-push';
2
2
  let vapidPublicKey = null;
3
3
  const subscriptions = new Map();
4
- const MAX_PAYLOAD_SIZE = 4 * 1024; // 4KB
5
4
  export function ensureVapidKeys(config, configPath, save) {
6
5
  if (config.vapidPublicKey && config.vapidPrivateKey) {
7
6
  vapidPublicKey = config.vapidPublicKey;
@@ -40,65 +39,15 @@ export function removeSession(sessionId) {
40
39
  entry.sessionIds.delete(sessionId);
41
40
  }
42
41
  }
43
- export function enrichNotification(event) {
44
- try {
45
- switch (event.type) {
46
- case 'tool_call': {
47
- const action = event.toolName || 'use a tool';
48
- const target = event.path || (event.toolInput && typeof event.toolInput === 'object'
49
- ? event.toolInput.file_path || event.toolInput.command || ''
50
- : '');
51
- const msg = target
52
- ? `Claude wants to ${action} ${target}`
53
- : `Claude wants to ${action}`;
54
- return msg.slice(0, 200);
55
- }
56
- case 'turn_completed':
57
- return 'Claude finished';
58
- case 'error': {
59
- const brief = (event.text || 'unknown error').slice(0, 150);
60
- return `Claude hit an error: ${brief}`;
61
- }
62
- default:
63
- return 'Claude is waiting for your input';
64
- }
65
- }
66
- catch {
67
- return 'Claude is waiting for your input';
68
- }
69
- }
70
- function truncatePayload(payload) {
71
- if (payload.length <= MAX_PAYLOAD_SIZE)
72
- return payload;
73
- // Try to parse, truncate text fields, and re-serialize
74
- try {
75
- const obj = JSON.parse(payload);
76
- if (typeof obj.enrichedMessage === 'string' && obj.enrichedMessage.length > 100) {
77
- obj.enrichedMessage = obj.enrichedMessage.slice(0, 100) + '...';
78
- }
79
- const truncated = JSON.stringify(obj);
80
- if (truncated.length <= MAX_PAYLOAD_SIZE)
81
- return truncated;
82
- }
83
- catch {
84
- // fall through
85
- }
86
- return payload.slice(0, MAX_PAYLOAD_SIZE);
87
- }
88
- export function notifySessionIdle(sessionId, session, sdkEvent) {
42
+ export function notifySessionIdle(sessionId, session) {
89
43
  if (!vapidPublicKey)
90
44
  return;
91
- const enrichedMessage = sdkEvent ? enrichNotification(sdkEvent) : undefined;
92
- const payloadObj = {
45
+ const payload = JSON.stringify({
93
46
  type: 'session-attention',
94
47
  sessionId,
95
48
  displayName: session.displayName,
96
49
  sessionType: session.type,
97
- };
98
- if (enrichedMessage) {
99
- payloadObj.enrichedMessage = enrichedMessage;
100
- }
101
- const payload = truncatePayload(JSON.stringify(payloadObj));
50
+ });
102
51
  for (const [endpoint, entry] of subscriptions) {
103
52
  if (!entry.sessionIds.has(sessionId))
104
53
  continue;