claude-remote-cli 2.15.16 → 3.0.3

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-DQ-fMetm.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-XlU0yxtO.css">
14
+ <script type="module" crossorigin src="/assets/index-CKQHbnTN.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-BgOmCV-k.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="app"></div>
@@ -11,7 +11,7 @@ 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 } from './sessions.js';
14
+ import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames, startSdkIdleSweep, stopSdkIdleSweep } 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';
@@ -156,6 +156,12 @@ async function main() {
156
156
  config.port = parseInt(process.env.CLAUDE_REMOTE_PORT, 10);
157
157
  if (process.env.CLAUDE_REMOTE_HOST)
158
158
  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
+ }
159
165
  push.ensureVapidKeys(config, CONFIG_PATH, saveConfig);
160
166
  if (!config.pinHash) {
161
167
  const pin = await promptPin('Set up a PIN for claude-remote-cli:');
@@ -884,6 +890,58 @@ async function main() {
884
890
  res.status(404).json({ error: 'Session not found' });
885
891
  }
886
892
  });
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
+ });
887
945
  // PATCH /sessions/:id — update displayName and persist to metadata
888
946
  app.patch('/sessions/:id', requireAuth, (req, res) => {
889
947
  const { displayName } = req.body;
@@ -991,9 +1049,15 @@ async function main() {
991
1049
  catch {
992
1050
  // tmux not installed or no sessions — ignore
993
1051
  }
1052
+ // Start SDK idle sweep
1053
+ startSdkIdleSweep();
994
1054
  function gracefulShutdown() {
995
1055
  server.close();
996
- // Kill all active sessions (PTY + tmux)
1056
+ stopSdkIdleSweep();
1057
+ // Serialize sessions to disk BEFORE killing them
1058
+ const configDir = path.dirname(CONFIG_PATH);
1059
+ serializeAll(configDir);
1060
+ // Kill all active sessions (PTY + tmux + SDK)
997
1061
  for (const s of sessions.list()) {
998
1062
  try {
999
1063
  sessions.kill(s.id);
@@ -0,0 +1,214 @@
1
+ import pty from 'node-pty';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { AGENT_COMMANDS, AGENT_CONTINUE_ARGS } from './types.js';
6
+ import { readMeta, writeMeta } from './config.js';
7
+ const IDLE_TIMEOUT_MS = 5000;
8
+ const MAX_SCROLLBACK = 256 * 1024; // 256KB max
9
+ export function generateTmuxSessionName(displayName, id) {
10
+ const sanitized = displayName.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').slice(0, 30);
11
+ return `crc-${sanitized}-${id.slice(0, 8)}`;
12
+ }
13
+ export function resolveTmuxSpawn(command, args, tmuxSessionName) {
14
+ return {
15
+ command: 'tmux',
16
+ args: [
17
+ '-u', 'new-session', '-s', tmuxSessionName, '--', command, ...args,
18
+ ';', 'set', 'set-clipboard', 'on',
19
+ ';', 'set', 'allow-passthrough', 'on',
20
+ ';', 'set', 'mode-keys', 'vi',
21
+ ],
22
+ };
23
+ }
24
+ export function createPtySession(params, sessionsMap, idleChangeCallbacks) {
25
+ const { id, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, tmuxSessionName: paramTmuxSessionName, initialScrollback, restored: paramRestored, } = params;
26
+ const createdAt = new Date().toISOString();
27
+ const resolvedCommand = command || AGENT_COMMANDS[agent];
28
+ // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
29
+ const env = Object.assign({}, process.env);
30
+ delete env.CLAUDECODE;
31
+ const useTmux = !command && !!paramUseTmux;
32
+ let spawnCommand = resolvedCommand;
33
+ let spawnArgs = args;
34
+ const tmuxSessionName = paramTmuxSessionName || (useTmux ? generateTmuxSessionName(displayName || repoName || 'session', id) : '');
35
+ if (useTmux) {
36
+ const tmux = resolveTmuxSpawn(resolvedCommand, args, tmuxSessionName);
37
+ spawnCommand = tmux.command;
38
+ spawnArgs = tmux.args;
39
+ }
40
+ const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
41
+ name: 'xterm-256color',
42
+ cols,
43
+ rows,
44
+ cwd: cwd || repoPath,
45
+ env,
46
+ });
47
+ // Scrollback buffer: stores all PTY output so we can replay on WebSocket (re)connect
48
+ const scrollback = initialScrollback ? [...initialScrollback] : [];
49
+ let scrollbackBytes = initialScrollback ? initialScrollback.reduce((sum, s) => sum + s.length, 0) : 0;
50
+ const resolvedCwd = cwd || repoPath;
51
+ const session = {
52
+ id,
53
+ type: type || 'worktree',
54
+ agent,
55
+ mode: 'pty',
56
+ root: root || '',
57
+ repoName: repoName || '',
58
+ repoPath,
59
+ worktreeName: worktreeName || '',
60
+ branchName: branchName || worktreeName || '',
61
+ displayName: displayName || worktreeName || repoName || '',
62
+ pty: ptyProcess,
63
+ createdAt,
64
+ lastActivity: createdAt,
65
+ scrollback,
66
+ idle: false,
67
+ cwd: resolvedCwd,
68
+ customCommand: command || null,
69
+ useTmux,
70
+ tmuxSessionName,
71
+ onPtyReplacedCallbacks: [],
72
+ status: 'active',
73
+ restored: paramRestored || false,
74
+ };
75
+ sessionsMap.set(id, session);
76
+ // Load existing metadata to preserve a previously-set displayName
77
+ if (configPath && worktreeName) {
78
+ const existing = readMeta(configPath, repoPath);
79
+ if (existing && existing.displayName) {
80
+ session.displayName = existing.displayName;
81
+ }
82
+ writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: createdAt });
83
+ }
84
+ let metaFlushTimer = null;
85
+ let idleTimer = null;
86
+ function resetIdleTimer() {
87
+ if (session.idle) {
88
+ session.idle = false;
89
+ for (const cb of idleChangeCallbacks)
90
+ cb(session.id, false);
91
+ }
92
+ if (idleTimer)
93
+ clearTimeout(idleTimer);
94
+ idleTimer = setTimeout(() => {
95
+ if (!session.idle) {
96
+ session.idle = true;
97
+ for (const cb of idleChangeCallbacks)
98
+ cb(session.id, true);
99
+ }
100
+ }, IDLE_TIMEOUT_MS);
101
+ }
102
+ const continueArgs = AGENT_CONTINUE_ARGS[agent];
103
+ function attachHandlers(proc, canRetry) {
104
+ const spawnTime = Date.now();
105
+ // Clear restored flag after 3s of running — means the PTY is healthy
106
+ const restoredClearTimer = session.restored ? setTimeout(() => { session.restored = false; }, 3000) : null;
107
+ proc.onData((data) => {
108
+ session.lastActivity = new Date().toISOString();
109
+ resetIdleTimer();
110
+ scrollback.push(data);
111
+ scrollbackBytes += data.length;
112
+ // Trim oldest entries if over limit
113
+ while (scrollbackBytes > MAX_SCROLLBACK && scrollback.length > 1) {
114
+ scrollbackBytes -= scrollback.shift().length;
115
+ }
116
+ if (configPath && worktreeName && !metaFlushTimer) {
117
+ metaFlushTimer = setTimeout(() => {
118
+ metaFlushTimer = null;
119
+ writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
120
+ }, 5000);
121
+ }
122
+ });
123
+ proc.onExit(() => {
124
+ if (canRetry && (Date.now() - spawnTime) < 3000) {
125
+ const retryArgs = args.filter(a => !continueArgs.includes(a));
126
+ const retryNotice = '\r\n[claude-remote-cli] --continue not available; starting new session...\r\n';
127
+ scrollback.length = 0;
128
+ scrollbackBytes = 0;
129
+ scrollback.push(retryNotice);
130
+ scrollbackBytes = retryNotice.length;
131
+ let retryCommand = resolvedCommand;
132
+ let retrySpawnArgs = retryArgs;
133
+ if (useTmux && tmuxSessionName) {
134
+ const retryTmuxName = tmuxSessionName + '-retry';
135
+ session.tmuxSessionName = retryTmuxName;
136
+ const tmux = resolveTmuxSpawn(resolvedCommand, retryArgs, retryTmuxName);
137
+ retryCommand = tmux.command;
138
+ retrySpawnArgs = tmux.args;
139
+ }
140
+ let retryPty;
141
+ try {
142
+ retryPty = pty.spawn(retryCommand, retrySpawnArgs, {
143
+ name: 'xterm-256color',
144
+ cols,
145
+ rows,
146
+ cwd: cwd || repoPath,
147
+ env,
148
+ });
149
+ }
150
+ catch {
151
+ // Retry spawn failed — fall through to normal exit cleanup
152
+ if (restoredClearTimer)
153
+ clearTimeout(restoredClearTimer);
154
+ if (idleTimer)
155
+ clearTimeout(idleTimer);
156
+ if (metaFlushTimer)
157
+ clearTimeout(metaFlushTimer);
158
+ sessionsMap.delete(id);
159
+ return;
160
+ }
161
+ session.pty = retryPty;
162
+ for (const cb of session.onPtyReplacedCallbacks)
163
+ cb(retryPty);
164
+ attachHandlers(retryPty, false);
165
+ return;
166
+ }
167
+ if (restoredClearTimer)
168
+ clearTimeout(restoredClearTimer);
169
+ // If PTY exited and this is a restored session, mark disconnected rather than delete
170
+ if (session.restored) {
171
+ session.status = 'disconnected';
172
+ session.restored = false; // clear so user-initiated kills can delete normally
173
+ if (idleTimer)
174
+ clearTimeout(idleTimer);
175
+ if (metaFlushTimer)
176
+ clearTimeout(metaFlushTimer);
177
+ return;
178
+ }
179
+ if (idleTimer)
180
+ clearTimeout(idleTimer);
181
+ if (metaFlushTimer)
182
+ clearTimeout(metaFlushTimer);
183
+ if (configPath && worktreeName) {
184
+ writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
185
+ }
186
+ sessionsMap.delete(id);
187
+ const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
188
+ fs.rm(tmpDir, { recursive: true, force: true }, () => { });
189
+ });
190
+ }
191
+ attachHandlers(ptyProcess, continueArgs.some(a => args.includes(a)));
192
+ const result = {
193
+ id,
194
+ type: session.type,
195
+ agent: session.agent,
196
+ mode: 'pty',
197
+ root: session.root,
198
+ repoName: session.repoName,
199
+ repoPath,
200
+ worktreeName: session.worktreeName,
201
+ branchName: session.branchName,
202
+ displayName: session.displayName,
203
+ pid: ptyProcess.pid,
204
+ createdAt,
205
+ lastActivity: createdAt,
206
+ idle: false,
207
+ cwd: resolvedCwd,
208
+ customCommand: command || null,
209
+ useTmux,
210
+ tmuxSessionName,
211
+ status: 'active',
212
+ };
213
+ return { session, result };
214
+ }
@@ -1,6 +1,7 @@
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
4
5
  export function ensureVapidKeys(config, configPath, save) {
5
6
  if (config.vapidPublicKey && config.vapidPrivateKey) {
6
7
  vapidPublicKey = config.vapidPublicKey;
@@ -39,15 +40,65 @@ export function removeSession(sessionId) {
39
40
  entry.sessionIds.delete(sessionId);
40
41
  }
41
42
  }
42
- export function notifySessionIdle(sessionId, session) {
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) {
43
89
  if (!vapidPublicKey)
44
90
  return;
45
- const payload = JSON.stringify({
91
+ const enrichedMessage = sdkEvent ? enrichNotification(sdkEvent) : undefined;
92
+ const payloadObj = {
46
93
  type: 'session-attention',
47
94
  sessionId,
48
95
  displayName: session.displayName,
49
96
  sessionType: session.type,
50
- });
97
+ };
98
+ if (enrichedMessage) {
99
+ payloadObj.enrichedMessage = enrichedMessage;
100
+ }
101
+ const payload = truncatePayload(JSON.stringify(payloadObj));
51
102
  for (const [endpoint, entry] of subscriptions) {
52
103
  if (!entry.sessionIds.has(sessionId))
53
104
  continue;