claude-remote-cli 2.14.3 → 2.15.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.
@@ -11,7 +11,7 @@
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-D390PVqN.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-BM9I3SdI.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-t15zfL9Q.css">
16
16
  </head>
17
17
  <body>
@@ -1,5 +1,43 @@
1
- // Minimal service worker for PWA install prompt support.
1
+ // Service worker for PWA install support and push notifications.
2
2
  // Does not cache — all requests pass through to the network.
3
3
  self.addEventListener('fetch', function (event) {
4
4
  event.respondWith(fetch(event.request));
5
5
  });
6
+
7
+ // Push notification handler
8
+ self.addEventListener('push', function (event) {
9
+ var data = {};
10
+ try { data = event.data.json(); } catch (e) { /* ignore parse errors */ }
11
+ event.waitUntil(
12
+ self.registration.showNotification(data.displayName || 'Claude Remote CLI', {
13
+ body: 'Session needs your input',
14
+ tag: 'session-' + (data.sessionId || ''),
15
+ data: { sessionId: data.sessionId, sessionType: data.sessionType },
16
+ })
17
+ );
18
+ });
19
+
20
+ // Notification click handler — focus existing tab or open new one
21
+ self.addEventListener('notificationclick', function (event) {
22
+ event.notification.close();
23
+ var notifData = event.notification.data || {};
24
+ var sessionId = notifData.sessionId;
25
+ var sessionType = notifData.sessionType;
26
+ if (!sessionId) return;
27
+ var tabMap = { repo: 'repos', worktree: 'worktrees' };
28
+ var tab = tabMap[sessionType] || 'repos';
29
+ var url = '/?session=' + sessionId + '&tab=' + tab;
30
+
31
+ event.waitUntil(
32
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
33
+ for (var i = 0; i < clientList.length; i++) {
34
+ var client = clientList[i];
35
+ if (client.url.indexOf(self.location.origin) !== -1) {
36
+ client.postMessage({ type: 'notification-click', sessionId: sessionId, sessionType: sessionType });
37
+ return client.focus();
38
+ }
39
+ }
40
+ return clients.openWindow(url);
41
+ })
42
+ );
43
+ });
@@ -12,6 +12,7 @@ export const DEFAULTS = {
12
12
  defaultContinue: true,
13
13
  defaultYolo: false,
14
14
  launchInTmux: false,
15
+ defaultNotifications: true,
15
16
  };
16
17
  export function loadConfig(configPath) {
17
18
  if (!fs.existsSync(configPath)) {
@@ -17,6 +17,7 @@ import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListP
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
+ import * as push from './push.js';
20
21
  const __filename = fileURLToPath(import.meta.url);
21
22
  const __dirname = path.dirname(__filename);
22
23
  const execFileAsync = promisify(execFile);
@@ -155,6 +156,7 @@ async function main() {
155
156
  config.port = parseInt(process.env.CLAUDE_REMOTE_PORT, 10);
156
157
  if (process.env.CLAUDE_REMOTE_HOST)
157
158
  config.host = process.env.CLAUDE_REMOTE_HOST;
159
+ push.ensureVapidKeys(config, CONFIG_PATH, saveConfig);
158
160
  if (!config.pinHash) {
159
161
  const pin = await promptPin('Set up a PIN for claude-remote-cli:');
160
162
  config.pinHash = await auth.hashPin(pin);
@@ -221,6 +223,15 @@ async function main() {
221
223
  watcher.rebuild(config.rootDirs || []);
222
224
  const server = http.createServer(app);
223
225
  const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher);
226
+ // Push notifications on session idle
227
+ sessions.onIdleChange((sessionId, idle) => {
228
+ if (idle) {
229
+ const session = sessions.get(sessionId);
230
+ if (session && session.type !== 'terminal') {
231
+ push.notifySessionIdle(sessionId, session);
232
+ }
233
+ }
234
+ });
224
235
  // POST /auth
225
236
  app.post('/auth', async (req, res) => {
226
237
  const ip = (req.ip || req.connection.remoteAddress);
@@ -535,6 +546,36 @@ async function main() {
535
546
  boolConfigEndpoints('launchInTmux', false, async () => {
536
547
  await execFileAsync('tmux', ['-V']);
537
548
  });
549
+ boolConfigEndpoints('defaultNotifications', true);
550
+ // GET /push/vapid-key
551
+ app.get('/push/vapid-key', requireAuth, (_req, res) => {
552
+ const key = push.getVapidPublicKey();
553
+ if (!key) {
554
+ res.status(501).json({ error: 'Push not available' });
555
+ return;
556
+ }
557
+ res.json({ vapidPublicKey: key });
558
+ });
559
+ // POST /push/subscribe
560
+ app.post('/push/subscribe', requireAuth, (req, res) => {
561
+ const { subscription, sessionIds } = req.body;
562
+ if (!subscription?.endpoint) {
563
+ res.status(400).json({ error: 'subscription required' });
564
+ return;
565
+ }
566
+ push.subscribe(subscription, sessionIds || []);
567
+ res.json({ ok: true });
568
+ });
569
+ // POST /push/unsubscribe
570
+ app.post('/push/unsubscribe', requireAuth, (req, res) => {
571
+ const { endpoint } = req.body;
572
+ if (!endpoint) {
573
+ res.status(400).json({ error: 'endpoint required' });
574
+ return;
575
+ }
576
+ push.unsubscribe(endpoint);
577
+ res.json({ ok: true });
578
+ });
538
579
  // DELETE /worktrees — remove a worktree, prune, and delete its branch
539
580
  app.delete('/worktrees', requireAuth, async (req, res) => {
540
581
  const { worktreePath, repoPath } = req.body;
@@ -827,8 +868,10 @@ async function main() {
827
868
  });
828
869
  // DELETE /sessions/:id
829
870
  app.delete('/sessions/:id', requireAuth, (req, res) => {
871
+ const id = req.params['id'];
830
872
  try {
831
- sessions.kill(req.params['id']);
873
+ sessions.kill(id);
874
+ push.removeSession(id);
832
875
  res.json({ ok: true });
833
876
  }
834
877
  catch (_) {
@@ -0,0 +1,60 @@
1
+ import webpush from 'web-push';
2
+ let vapidPublicKey = null;
3
+ const subscriptions = new Map();
4
+ export function ensureVapidKeys(config, configPath, save) {
5
+ if (config.vapidPublicKey && config.vapidPrivateKey) {
6
+ vapidPublicKey = config.vapidPublicKey;
7
+ webpush.setVapidDetails('mailto:noreply@claude-remote-cli.local', config.vapidPublicKey, config.vapidPrivateKey);
8
+ return;
9
+ }
10
+ try {
11
+ const keys = webpush.generateVAPIDKeys();
12
+ config.vapidPublicKey = keys.publicKey;
13
+ config.vapidPrivateKey = keys.privateKey;
14
+ save(configPath, config);
15
+ vapidPublicKey = keys.publicKey;
16
+ webpush.setVapidDetails('mailto:noreply@claude-remote-cli.local', keys.publicKey, keys.privateKey);
17
+ }
18
+ catch {
19
+ // VAPID key generation failed — push will be unavailable
20
+ vapidPublicKey = null;
21
+ }
22
+ }
23
+ export function getVapidPublicKey() {
24
+ return vapidPublicKey;
25
+ }
26
+ export function subscribe(subscription, sessionIds) {
27
+ // Replace the full session list for this endpoint — the client sends
28
+ // the complete set of sessions it wants notifications for.
29
+ subscriptions.set(subscription.endpoint, {
30
+ subscription,
31
+ sessionIds: new Set(sessionIds),
32
+ });
33
+ }
34
+ export function unsubscribe(endpoint) {
35
+ subscriptions.delete(endpoint);
36
+ }
37
+ export function removeSession(sessionId) {
38
+ for (const entry of subscriptions.values()) {
39
+ entry.sessionIds.delete(sessionId);
40
+ }
41
+ }
42
+ export function notifySessionIdle(sessionId, session) {
43
+ if (!vapidPublicKey)
44
+ return;
45
+ const payload = JSON.stringify({
46
+ type: 'session-attention',
47
+ sessionId,
48
+ displayName: session.displayName,
49
+ sessionType: session.type,
50
+ });
51
+ for (const [endpoint, entry] of subscriptions) {
52
+ if (!entry.sessionIds.has(sessionId))
53
+ continue;
54
+ webpush.sendNotification(entry.subscription, payload).catch((err) => {
55
+ if (err.statusCode === 410 || err.statusCode === 404) {
56
+ subscriptions.delete(endpoint);
57
+ }
58
+ });
59
+ }
60
+ }
@@ -38,9 +38,9 @@ function resolveTmuxSpawn(command, args, tmuxSessionName) {
38
38
  const sessions = new Map();
39
39
  const IDLE_TIMEOUT_MS = 5000;
40
40
  let terminalCounter = 0;
41
- let idleChangeCallback = null;
41
+ const idleChangeCallbacks = [];
42
42
  function onIdleChange(cb) {
43
- idleChangeCallback = cb;
43
+ idleChangeCallbacks.push(cb);
44
44
  }
45
45
  function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux }) {
46
46
  const id = crypto.randomBytes(8).toString('hex');
@@ -102,16 +102,16 @@ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktre
102
102
  function resetIdleTimer() {
103
103
  if (session.idle) {
104
104
  session.idle = false;
105
- if (idleChangeCallback)
106
- idleChangeCallback(session.id, false);
105
+ for (const cb of idleChangeCallbacks)
106
+ cb(session.id, false);
107
107
  }
108
108
  if (idleTimer)
109
109
  clearTimeout(idleTimer);
110
110
  idleTimer = setTimeout(() => {
111
111
  if (!session.idle) {
112
112
  session.idle = true;
113
- if (idleChangeCallback)
114
- idleChangeCallback(session.id, true);
113
+ for (const cb of idleChangeCallbacks)
114
+ cb(session.id, true);
115
115
  }
116
116
  }, IDLE_TIMEOUT_MS);
117
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "2.14.3",
3
+ "version": "2.15.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
@@ -50,6 +50,7 @@
50
50
  "express": "^4.21.0",
51
51
  "node-pty": "^1.0.0",
52
52
  "svelte": "^5.53.3",
53
+ "web-push": "^3.6.7",
53
54
  "ws": "^8.18.0"
54
55
  },
55
56
  "devDependencies": {
@@ -59,6 +60,7 @@
59
60
  "@types/cookie-parser": "^1.4.7",
60
61
  "@types/express": "^4.17.21",
61
62
  "@types/node": "^22.0.0",
63
+ "@types/web-push": "^3.6.4",
62
64
  "@types/ws": "^8.5.13",
63
65
  "playwright": "^1.58.2",
64
66
  "svelte-check": "^4.4.3",