claude-remote-cli 3.0.5 → 3.0.9

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.
package/dist/server/ws.js CHANGED
@@ -1,5 +1,17 @@
1
1
  import { WebSocketServer } from 'ws';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
2
4
  import * as sessions from './sessions.js';
5
+ import { onSdkEvent, sendMessage as sdkSendMessage, handlePermission as sdkHandlePermission } from './sdk-handler.js';
6
+ import { writeMeta } from './config.js';
7
+ const execFileAsync = promisify(execFile);
8
+ const BACKPRESSURE_HIGH = 1024 * 1024; // 1MB
9
+ const BACKPRESSURE_LOW = 512 * 1024; // 512KB
10
+ const RENAME_CORE = `rename the current git branch using \`git branch -m <new-name>\` to a short, descriptive kebab-case name based on the task I'm asking about. Do not include any ticket numbers or prefixes.`;
11
+ // Prepended to the user's first message in SDK mode
12
+ const SDK_BRANCH_RENAME_INSTRUCTION = `Before responding to my message, first ${RENAME_CORE} After renaming, proceed with my request normally.\n\n`;
13
+ // Sent as a standalone first message in PTY mode, before the user types
14
+ const PTY_BRANCH_RENAME_INSTRUCTION = `When I send my next message, before responding to it, first ${RENAME_CORE} After renaming, proceed with my request normally. Reply with only "Ready." and nothing else.`;
3
15
  function parseCookies(cookieHeader) {
4
16
  const cookies = {};
5
17
  if (!cookieHeader)
@@ -14,7 +26,39 @@ function parseCookies(cookieHeader) {
14
26
  });
15
27
  return cookies;
16
28
  }
17
- function setupWebSocket(server, authenticatedTokens, watcher) {
29
+ const BRANCH_POLL_INTERVAL_MS = 3000;
30
+ const BRANCH_POLL_MAX_ATTEMPTS = 10;
31
+ function startBranchWatcher(session, broadcastEvent, configPath) {
32
+ const originalBranch = session.branchName;
33
+ let attempts = 0;
34
+ const timer = setInterval(async () => {
35
+ attempts++;
36
+ if (attempts > BRANCH_POLL_MAX_ATTEMPTS) {
37
+ clearInterval(timer);
38
+ return;
39
+ }
40
+ try {
41
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: session.cwd });
42
+ const currentBranch = stdout.trim();
43
+ if (currentBranch && currentBranch !== originalBranch) {
44
+ clearInterval(timer);
45
+ session.branchName = currentBranch;
46
+ session.displayName = currentBranch;
47
+ broadcastEvent('session-renamed', { sessionId: session.id, branchName: currentBranch, displayName: currentBranch });
48
+ writeMeta(configPath, {
49
+ worktreePath: session.repoPath,
50
+ displayName: currentBranch,
51
+ lastActivity: new Date().toISOString(),
52
+ branchName: currentBranch,
53
+ });
54
+ }
55
+ }
56
+ catch {
57
+ // git command failed — session cwd may not exist yet, retry
58
+ }
59
+ }, BRANCH_POLL_INTERVAL_MS);
60
+ }
61
+ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
18
62
  const wss = new WebSocketServer({ noServer: true });
19
63
  const eventClients = new Set();
20
64
  function broadcastEvent(type, data) {
@@ -47,7 +91,7 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
47
91
  });
48
92
  return;
49
93
  }
50
- // PTY channel: /ws/:sessionId
94
+ // PTY/SDK channel: /ws/:sessionId
51
95
  const match = request.url && request.url.match(/^\/ws\/([a-f0-9]+)$/);
52
96
  if (!match) {
53
97
  socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
@@ -71,6 +115,16 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
71
115
  const session = sessionMap.get(ws);
72
116
  if (!session)
73
117
  return;
118
+ if (session.mode === 'sdk') {
119
+ handleSdkConnection(ws, session);
120
+ return;
121
+ }
122
+ // PTY mode — existing behavior
123
+ if (session.mode !== 'pty') {
124
+ ws.close(1008, 'Session mode does not support PTY streaming');
125
+ return;
126
+ }
127
+ const ptySession = session;
74
128
  let dataDisposable = null;
75
129
  let exitDisposable = null;
76
130
  function attachToPty(ptyProcess) {
@@ -78,7 +132,7 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
78
132
  dataDisposable?.dispose();
79
133
  exitDisposable?.dispose();
80
134
  // Replay scrollback
81
- for (const chunk of session.scrollback) {
135
+ for (const chunk of ptySession.scrollback) {
82
136
  if (ws.readyState === ws.OPEN)
83
137
  ws.send(chunk);
84
138
  }
@@ -91,52 +145,125 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
91
145
  ws.close(1000);
92
146
  });
93
147
  }
94
- attachToPty(session.pty);
148
+ attachToPty(ptySession.pty);
149
+ // For PTY sessions needing branch rename, send the rename instruction once Claude CLI is ready.
150
+ // We watch for PTY idle (Claude shows its prompt and waits for input) as the trigger.
151
+ let pendingIdleHandler = null;
152
+ if (ptySession.needsBranchRename) {
153
+ ptySession.needsBranchRename = false;
154
+ const idleHandler = (sessionId, idle) => {
155
+ if (idle && sessionId === ptySession.id) {
156
+ sessions.offIdleChange(idleHandler);
157
+ pendingIdleHandler = null;
158
+ ptySession.pty.write(PTY_BRANCH_RENAME_INSTRUCTION + '\r');
159
+ startBranchWatcher(ptySession, broadcastEvent, configPath);
160
+ }
161
+ };
162
+ pendingIdleHandler = idleHandler;
163
+ sessions.onIdleChange(idleHandler);
164
+ }
95
165
  const ptyReplacedHandler = (newPty) => attachToPty(newPty);
96
- session.onPtyReplacedCallbacks.push(ptyReplacedHandler);
166
+ ptySession.onPtyReplacedCallbacks.push(ptyReplacedHandler);
97
167
  ws.on('message', (msg) => {
98
168
  const str = msg.toString();
99
169
  try {
100
170
  const parsed = JSON.parse(str);
101
171
  if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
102
- sessions.resize(session.id, parsed.cols, parsed.rows);
172
+ sessions.resize(ptySession.id, parsed.cols, parsed.rows);
103
173
  return;
104
174
  }
105
175
  }
106
176
  catch (_) { }
107
- // Branch rename interception: prepend rename prompt before the user's first message
108
- if (session.needsBranchRename) {
109
- if (!session._renameBuffer)
110
- session._renameBuffer = '';
111
- const enterIndex = str.indexOf('\r');
112
- if (enterIndex === -1) {
113
- // No Enter yet — buffer and pass through so the user sees echo
114
- session._renameBuffer += str;
115
- session.pty.write(str);
116
- return;
117
- }
118
- // Enter detected — inject rename prompt before the user's message
119
- const buffered = session._renameBuffer;
120
- const beforeEnter = buffered + str.slice(0, enterIndex);
121
- const afterEnter = str.slice(enterIndex); // includes the \r
122
- const renamePrompt = `Before doing anything else, rename the current git branch using \`git branch -m <new-name>\`. Choose a short, descriptive kebab-case branch name based on the task below.${session.branchRenamePrompt ? ' User preferences: ' + session.branchRenamePrompt : ''} Do not ask for confirmation — just rename and proceed.\n\n`;
123
- const clearLine = '\x15'; // Ctrl+U clears the current input line
124
- session.pty.write(clearLine + renamePrompt + beforeEnter + afterEnter);
125
- session.needsBranchRename = false;
126
- delete session._renameBuffer;
127
- return;
128
- }
129
- // Use session.pty dynamically so writes go to current PTY
130
- session.pty.write(str);
177
+ // Use ptySession.pty dynamically so writes go to current PTY
178
+ ptySession.pty.write(str);
131
179
  });
132
180
  ws.on('close', () => {
133
181
  dataDisposable?.dispose();
134
182
  exitDisposable?.dispose();
135
- const idx = session.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
183
+ if (pendingIdleHandler)
184
+ sessions.offIdleChange(pendingIdleHandler);
185
+ const idx = ptySession.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
136
186
  if (idx !== -1)
137
- session.onPtyReplacedCallbacks.splice(idx, 1);
187
+ ptySession.onPtyReplacedCallbacks.splice(idx, 1);
138
188
  });
139
189
  });
190
+ function handleSdkConnection(ws, session) {
191
+ // Send session info
192
+ const sessionInfo = JSON.stringify({
193
+ type: 'session_info',
194
+ mode: 'sdk',
195
+ sessionId: session.id,
196
+ });
197
+ if (ws.readyState === ws.OPEN)
198
+ ws.send(sessionInfo);
199
+ // Replay stored events (send as-is — client expects raw SdkEvent shape)
200
+ for (const event of session.events) {
201
+ if (ws.readyState !== ws.OPEN)
202
+ break;
203
+ ws.send(JSON.stringify(event));
204
+ }
205
+ // Subscribe to live events with backpressure
206
+ let paused = false;
207
+ const unsubscribe = onSdkEvent(session.id, (event) => {
208
+ if (ws.readyState !== ws.OPEN)
209
+ return;
210
+ // Backpressure check
211
+ if (ws.bufferedAmount > BACKPRESSURE_HIGH) {
212
+ paused = true;
213
+ return;
214
+ }
215
+ ws.send(JSON.stringify(event));
216
+ });
217
+ // Periodically check if we can resume
218
+ const backpressureInterval = setInterval(() => {
219
+ if (paused && ws.bufferedAmount < BACKPRESSURE_LOW) {
220
+ paused = false;
221
+ }
222
+ }, 100);
223
+ // Handle incoming messages
224
+ ws.on('message', (msg) => {
225
+ const str = msg.toString();
226
+ try {
227
+ const parsed = JSON.parse(str);
228
+ if (parsed.type === 'message' && typeof parsed.text === 'string') {
229
+ if (parsed.text.length > 100_000)
230
+ return;
231
+ if (session.needsBranchRename) {
232
+ session.needsBranchRename = false;
233
+ sdkSendMessage(session.id, SDK_BRANCH_RENAME_INSTRUCTION + parsed.text);
234
+ startBranchWatcher(session, broadcastEvent, configPath);
235
+ }
236
+ else {
237
+ sdkSendMessage(session.id, parsed.text);
238
+ }
239
+ return;
240
+ }
241
+ if (parsed.type === 'permission' && typeof parsed.requestId === 'string' && typeof parsed.approved === 'boolean') {
242
+ sdkHandlePermission(session.id, parsed.requestId, parsed.approved);
243
+ return;
244
+ }
245
+ if (parsed.type === 'resize' && typeof parsed.cols === 'number' && typeof parsed.rows === 'number') {
246
+ // TODO: wire up companion shell — currently open_companion message is unhandled server-side
247
+ return;
248
+ }
249
+ if (parsed.type === 'open_companion') {
250
+ // TODO: spawn companion PTY in session CWD and relay via terminal_data/terminal_exit frames
251
+ return;
252
+ }
253
+ }
254
+ catch (_) {
255
+ // Not JSON — ignore for SDK sessions
256
+ }
257
+ });
258
+ ws.on('close', () => {
259
+ unsubscribe();
260
+ clearInterval(backpressureInterval);
261
+ });
262
+ ws.on('error', () => {
263
+ unsubscribe();
264
+ clearInterval(backpressureInterval);
265
+ });
266
+ }
140
267
  sessions.onIdleChange((sessionId, idle) => {
141
268
  broadcastEvent('session-idle-changed', { sessionId, idle });
142
269
  });
@@ -0,0 +1,28 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { MOUNTAIN_NAMES } from '../server/types.js';
4
+ describe('MOUNTAIN_NAMES', () => {
5
+ test('contains 30 mountain names', () => {
6
+ assert.equal(MOUNTAIN_NAMES.length, 30);
7
+ });
8
+ test('all names are lowercase kebab-case', () => {
9
+ for (const name of MOUNTAIN_NAMES) {
10
+ assert.match(name, /^[a-z][a-z0-9-]*$/, `Mountain name "${name}" is not kebab-case`);
11
+ }
12
+ });
13
+ test('no duplicate names', () => {
14
+ const unique = new Set(MOUNTAIN_NAMES);
15
+ assert.equal(unique.size, MOUNTAIN_NAMES.length);
16
+ });
17
+ test('cycling wraps around at array length', () => {
18
+ let idx = 28;
19
+ const name1 = MOUNTAIN_NAMES[idx % MOUNTAIN_NAMES.length];
20
+ idx++;
21
+ const name2 = MOUNTAIN_NAMES[idx % MOUNTAIN_NAMES.length];
22
+ idx++;
23
+ const name3 = MOUNTAIN_NAMES[idx % MOUNTAIN_NAMES.length];
24
+ assert.equal(name1, 'whitney');
25
+ assert.equal(name2, 'hood');
26
+ assert.equal(name3, 'everest'); // wraps back to start
27
+ });
28
+ });
@@ -7,6 +7,7 @@ describe('PullRequest types', () => {
7
7
  title: 'Fix bug',
8
8
  url: 'https://github.com/owner/repo/pull/42',
9
9
  headRefName: 'fix/bug',
10
+ baseRefName: 'main',
10
11
  state: 'OPEN',
11
12
  author: 'testuser',
12
13
  role: 'author',
@@ -24,6 +25,7 @@ describe('PullRequest types', () => {
24
25
  title: 'Add feature',
25
26
  url: 'https://github.com/owner/repo/pull/43',
26
27
  headRefName: 'feat/new',
28
+ baseRefName: 'main',
27
29
  state: 'OPEN',
28
30
  author: 'otheruser',
29
31
  role: 'reviewer',
@@ -49,6 +51,7 @@ describe('PullRequest types', () => {
49
51
  title: 'Test',
50
52
  url: 'https://github.com/o/r/pull/1',
51
53
  headRefName: 'test',
54
+ baseRefName: 'main',
52
55
  state: 'OPEN',
53
56
  author: 'user',
54
57
  role: 'author',
@@ -60,6 +60,7 @@ describe('sessions', () => {
60
60
  assert.ok(session, 'should return the session');
61
61
  assert.strictEqual(session.id, result.id);
62
62
  assert.strictEqual(session.repoName, 'test-repo');
63
+ assert.strictEqual(session.mode, 'pty');
63
64
  assert.ok(session.pty, 'get should include the pty object');
64
65
  });
65
66
  it('get returns undefined for nonexistent id', () => {
@@ -100,8 +101,10 @@ describe('sessions', () => {
100
101
  createdIds.push(result.id);
101
102
  const session = sessions.get(result.id);
102
103
  assert.ok(session);
104
+ assert.strictEqual(session.mode, 'pty');
105
+ const ptySession = session;
103
106
  let output = '';
104
- session.pty.onData((data) => {
107
+ ptySession.pty.onData((data) => {
105
108
  output += data;
106
109
  if (output.includes('hello')) {
107
110
  done();
@@ -388,9 +391,11 @@ describe('sessions', () => {
388
391
  createdIds.push(result.id);
389
392
  const session = sessions.get(result.id);
390
393
  assert.ok(session);
391
- session.onPtyReplacedCallbacks.push((newPty) => {
394
+ assert.strictEqual(session.mode, 'pty');
395
+ const ptySession = session;
396
+ ptySession.onPtyReplacedCallbacks.push((newPty) => {
392
397
  assert.ok(newPty, 'should receive new PTY');
393
- assert.strictEqual(session.pty, newPty, 'session.pty should be updated to new PTY');
398
+ assert.strictEqual(ptySession.pty, newPty, 'session.pty should be updated to new PTY');
394
399
  done();
395
400
  });
396
401
  });
@@ -404,7 +409,9 @@ describe('sessions', () => {
404
409
  createdIds.push(result.id);
405
410
  const session = sessions.get(result.id);
406
411
  assert.ok(session);
407
- session.onPtyReplacedCallbacks.push(() => {
412
+ assert.strictEqual(session.mode, 'pty');
413
+ const ptySession = session;
414
+ ptySession.onPtyReplacedCallbacks.push(() => {
408
415
  const stillExists = sessions.get(result.id);
409
416
  assert.ok(stillExists, 'session should still exist after retry');
410
417
  done();
@@ -420,9 +427,11 @@ describe('sessions', () => {
420
427
  createdIds.push(result.id);
421
428
  const session = sessions.get(result.id);
422
429
  assert.ok(session);
423
- session.onPtyReplacedCallbacks.push((newPty) => {
430
+ assert.strictEqual(session.mode, 'pty');
431
+ const ptySession = session;
432
+ ptySession.onPtyReplacedCallbacks.push((newPty) => {
424
433
  assert.ok(newPty, 'should receive new PTY even with exit code 0');
425
- assert.strictEqual(session.pty, newPty, 'session.pty should be updated');
434
+ assert.strictEqual(ptySession.pty, newPty, 'session.pty should be updated');
426
435
  const stillExists = sessions.get(result.id);
427
436
  assert.ok(stillExists, 'session should still exist after retry');
428
437
  done();
@@ -452,6 +461,7 @@ describe('sessions', () => {
452
461
  createdIds.push(result.id);
453
462
  const session = sessions.get(result.id);
454
463
  assert.ok(session);
464
+ assert.strictEqual(session.mode, 'pty');
455
465
  assert.ok(session.scrollback.length >= 1);
456
466
  assert.strictEqual(session.scrollback[0], 'prior output\r\n');
457
467
  });
@@ -489,6 +499,7 @@ describe('session persistence', () => {
489
499
  // Manually push some scrollback
490
500
  const session = sessions.get(s.id);
491
501
  assert.ok(session);
502
+ assert.strictEqual(session.mode, 'pty');
492
503
  session.scrollback.push('hello world');
493
504
  serializeAll(configDir);
494
505
  // Check pending-sessions.json
@@ -519,6 +530,7 @@ describe('session persistence', () => {
519
530
  const originalId = s.id;
520
531
  const session = sessions.get(originalId);
521
532
  assert.ok(session);
533
+ assert.strictEqual(session.mode, 'pty');
522
534
  session.scrollback.push('saved output');
523
535
  serializeAll(configDir);
524
536
  // Kill the original session
@@ -533,6 +545,7 @@ describe('session persistence', () => {
533
545
  assert.strictEqual(restoredSession.repoPath, '/tmp');
534
546
  assert.strictEqual(restoredSession.displayName, 'my-session');
535
547
  // Scrollback should be restored
548
+ assert.strictEqual(restoredSession.mode, 'pty');
536
549
  assert.ok(restoredSession.scrollback.length >= 1);
537
550
  assert.strictEqual(restoredSession.scrollback[0], 'saved output');
538
551
  // pending-sessions.json should be cleaned up
@@ -597,7 +610,7 @@ describe('session persistence', () => {
597
610
  lastActivity: new Date().toISOString(),
598
611
  useTmux: true,
599
612
  tmuxSessionName: 'crc-my-session-tmux-tes',
600
- customCommand: null,
613
+ customCommand: '/bin/cat', // Use /bin/cat to avoid spawning real claude binary in test
601
614
  cwd: '/tmp',
602
615
  }],
603
616
  };
@@ -606,6 +619,7 @@ describe('session persistence', () => {
606
619
  assert.strictEqual(restored, 1);
607
620
  const session = sessions.get('tmux-test-id');
608
621
  assert.ok(session, 'restored session should exist');
622
+ assert.strictEqual(session.mode, 'pty');
609
623
  assert.strictEqual(session.tmuxSessionName, 'crc-my-session-tmux-tes', 'tmuxSessionName should be preserved from serialized data');
610
624
  });
611
625
  it('restored session remains in list after PTY exits (disconnected status)', async () => {
@@ -705,6 +719,7 @@ describe('session persistence', () => {
705
719
  // Verify tmux session name survived the round trip
706
720
  const restoredTmux = sessions.get('tmux-roundtrip-id');
707
721
  assert.ok(restoredTmux);
722
+ assert.strictEqual(restoredTmux.mode, 'pty');
708
723
  assert.strictEqual(restoredTmux.tmuxSessionName, 'crc-tmux-session-tmux-rou');
709
724
  assert.strictEqual(restoredTmux.displayName, 'Tmux Session');
710
725
  });
@@ -719,6 +734,7 @@ describe('session persistence', () => {
719
734
  });
720
735
  const session = sessions.get(s.id);
721
736
  assert.ok(session);
737
+ assert.strictEqual(session.mode, 'pty');
722
738
  session.scrollback.push('important output');
723
739
  serializeAll(configDir);
724
740
  // Kill after serialize (mimics gracefulShutdown sequence)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "3.0.5",
3
+ "version": "3.0.9",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
@@ -42,6 +42,7 @@
42
42
  "license": "MIT",
43
43
  "author": "Donovan Yohan",
44
44
  "dependencies": {
45
+ "@anthropic-ai/claude-agent-sdk": "^0.2.77",
45
46
  "@tanstack/svelte-query": "^6.0.18",
46
47
  "@xterm/addon-fit": "^0.11.0",
47
48
  "@xterm/xterm": "^6.0.0",