claude-remote-cli 2.11.0 → 2.12.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-DLO1cUOf.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-CEJznk5F.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-nIPDa7NP.css">
16
16
  </head>
17
17
  <body>
@@ -9,6 +9,9 @@ export const DEFAULTS = {
9
9
  claudeCommand: 'claude',
10
10
  claudeArgs: [],
11
11
  defaultAgent: 'claude',
12
+ defaultContinue: true,
13
+ defaultYolo: false,
14
+ launchInTmux: false,
12
15
  };
13
16
  export function loadConfig(configPath) {
14
17
  if (!fs.existsSync(configPath)) {
@@ -193,6 +193,30 @@ async function main() {
193
193
  }
194
194
  next();
195
195
  };
196
+ function boolConfigEndpoints(name, defaultValue, onEnable) {
197
+ app.get(`/config/${name}`, requireAuth, (_req, res) => {
198
+ res.json({ [name]: config[name] ?? defaultValue });
199
+ });
200
+ app.patch(`/config/${name}`, requireAuth, async (req, res) => {
201
+ const value = req.body[name];
202
+ if (typeof value !== 'boolean') {
203
+ res.status(400).json({ error: `${name} must be a boolean` });
204
+ return;
205
+ }
206
+ if (value && onEnable) {
207
+ try {
208
+ await onEnable();
209
+ }
210
+ catch {
211
+ res.status(400).json({ error: `Validation failed for ${name}` });
212
+ return;
213
+ }
214
+ }
215
+ config[name] = value;
216
+ saveConfig(CONFIG_PATH, config);
217
+ res.json({ [name]: value });
218
+ });
219
+ }
196
220
  const watcher = new WorktreeWatcher();
197
221
  watcher.rebuild(config.rootDirs || []);
198
222
  const server = http.createServer(app);
@@ -506,6 +530,11 @@ async function main() {
506
530
  saveConfig(CONFIG_PATH, config);
507
531
  res.json({ defaultAgent: config.defaultAgent });
508
532
  });
533
+ boolConfigEndpoints('defaultContinue', true);
534
+ boolConfigEndpoints('defaultYolo', false);
535
+ boolConfigEndpoints('launchInTmux', false, async () => {
536
+ await execFileAsync('tmux', ['-V']);
537
+ });
509
538
  // DELETE /worktrees — remove a worktree, prune, and delete its branch
510
539
  app.delete('/worktrees', requireAuth, async (req, res) => {
511
540
  const { worktreePath, repoPath } = req.body;
@@ -580,7 +609,7 @@ async function main() {
580
609
  });
581
610
  // POST /sessions
582
611
  app.post('/sessions', requireAuth, async (req, res) => {
583
- const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent } = req.body;
612
+ const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent, useTmux } = req.body;
584
613
  if (!repoPath) {
585
614
  res.status(400).json({ error: 'repoPath is required' });
586
615
  return;
@@ -666,6 +695,7 @@ async function main() {
666
695
  root,
667
696
  displayName: name,
668
697
  args: baseArgs,
698
+ useTmux: useTmux ?? config.launchInTmux,
669
699
  });
670
700
  res.status(201).json(repoSession);
671
701
  return;
@@ -689,6 +719,7 @@ async function main() {
689
719
  displayName: displayNameVal,
690
720
  args,
691
721
  configPath: CONFIG_PATH,
722
+ useTmux: useTmux ?? config.launchInTmux,
692
723
  });
693
724
  writeMeta(CONFIG_PATH, {
694
725
  worktreePath: sessionRepoPath,
@@ -731,6 +762,7 @@ async function main() {
731
762
  displayName,
732
763
  args,
733
764
  configPath: CONFIG_PATH,
765
+ useTmux: useTmux ?? config.launchInTmux,
734
766
  });
735
767
  if (!worktreePath) {
736
768
  writeMeta(CONFIG_PATH, {
@@ -744,7 +776,7 @@ async function main() {
744
776
  });
745
777
  // POST /sessions/repo — start a session in the repo root (no worktree)
746
778
  app.post('/sessions/repo', requireAuth, (req, res) => {
747
- const { repoPath, repoName, continue: continueSession, claudeArgs, yolo, agent } = req.body;
779
+ const { repoPath, repoName, continue: continueSession, claudeArgs, yolo, agent, useTmux } = req.body;
748
780
  if (!repoPath) {
749
781
  res.status(400).json({ error: 'repoPath is required' });
750
782
  return;
@@ -774,6 +806,7 @@ async function main() {
774
806
  root,
775
807
  displayName: name,
776
808
  args,
809
+ useTmux: useTmux ?? config.launchInTmux,
777
810
  });
778
811
  res.status(201).json(session);
779
812
  });
@@ -889,6 +922,34 @@ async function main() {
889
922
  res.status(500).json({ ok: false, error: message });
890
923
  }
891
924
  });
925
+ // Clean up orphaned tmux sessions from previous runs
926
+ try {
927
+ const { stdout } = await execFileAsync('tmux', ['list-sessions', '-F', '#{session_name}']);
928
+ const crcSessions = stdout.trim().split('\n').filter(name => name.startsWith('crc-'));
929
+ for (const name of crcSessions) {
930
+ execFileAsync('tmux', ['kill-session', '-t', name]).catch(() => { });
931
+ }
932
+ if (crcSessions.length > 0) {
933
+ console.log(`Cleaned up ${crcSessions.length} orphaned tmux session(s).`);
934
+ }
935
+ }
936
+ catch {
937
+ // tmux not installed or no sessions — ignore
938
+ }
939
+ function gracefulShutdown() {
940
+ server.close();
941
+ // Kill all active sessions (PTY + tmux)
942
+ for (const s of sessions.list()) {
943
+ try {
944
+ sessions.kill(s.id);
945
+ }
946
+ catch { /* already exiting */ }
947
+ }
948
+ // Brief delay to let async tmux kill-session calls fire
949
+ setTimeout(() => process.exit(0), 200);
950
+ }
951
+ process.on('SIGTERM', gracefulShutdown);
952
+ process.on('SIGINT', gracefulShutdown);
892
953
  server.listen(config.port, config.host, () => {
893
954
  console.log(`claude-remote-cli listening on ${config.host}:${config.port}`);
894
955
  });
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+ import { execFile } from 'node:child_process';
6
7
  import { readMeta, writeMeta } from './config.js';
7
8
  const AGENT_COMMANDS = {
8
9
  claude: 'claude',
@@ -16,6 +17,16 @@ const AGENT_YOLO_ARGS = {
16
17
  claude: ['--dangerously-skip-permissions'],
17
18
  codex: ['--full-auto'],
18
19
  };
20
+ function generateTmuxSessionName(displayName, id) {
21
+ const sanitized = displayName.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').slice(0, 30);
22
+ return `crc-${sanitized}-${id.slice(0, 8)}`;
23
+ }
24
+ function resolveTmuxSpawn(command, args, tmuxSessionName) {
25
+ return {
26
+ command: 'tmux',
27
+ args: ['new-session', '-s', tmuxSessionName, '--', command, ...args],
28
+ };
29
+ }
19
30
  // In-memory registry: id -> Session
20
31
  const sessions = new Map();
21
32
  const IDLE_TIMEOUT_MS = 5000;
@@ -24,14 +35,23 @@ let idleChangeCallback = null;
24
35
  function onIdleChange(cb) {
25
36
  idleChangeCallback = cb;
26
37
  }
27
- function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath }) {
38
+ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux }) {
28
39
  const id = crypto.randomBytes(8).toString('hex');
29
40
  const createdAt = new Date().toISOString();
30
41
  const resolvedCommand = command || AGENT_COMMANDS[agent];
31
42
  // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
32
43
  const env = Object.assign({}, process.env);
33
44
  delete env.CLAUDECODE;
34
- const ptyProcess = pty.spawn(resolvedCommand, args, {
45
+ const useTmux = !command && !!paramUseTmux;
46
+ let spawnCommand = resolvedCommand;
47
+ let spawnArgs = args;
48
+ const tmuxSessionName = useTmux ? generateTmuxSessionName(displayName || repoName || 'session', id) : '';
49
+ if (useTmux) {
50
+ const tmux = resolveTmuxSpawn(resolvedCommand, args, tmuxSessionName);
51
+ spawnCommand = tmux.command;
52
+ spawnArgs = tmux.args;
53
+ }
54
+ const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
35
55
  name: 'xterm-256color',
36
56
  cols,
37
57
  rows,
@@ -57,6 +77,8 @@ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktre
57
77
  lastActivity: createdAt,
58
78
  scrollback,
59
79
  idle: false,
80
+ useTmux,
81
+ tmuxSessionName,
60
82
  };
61
83
  sessions.set(id, session);
62
84
  // Load existing metadata to preserve a previously-set displayName
@@ -110,7 +132,14 @@ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktre
110
132
  const retryArgs = args.filter(a => !continueArgs.includes(a));
111
133
  scrollback.length = 0;
112
134
  scrollbackBytes = 0;
113
- const retryPty = pty.spawn(resolvedCommand, retryArgs, {
135
+ let retryCommand = resolvedCommand;
136
+ let retrySpawnArgs = retryArgs;
137
+ if (useTmux && tmuxSessionName) {
138
+ const tmux = resolveTmuxSpawn(resolvedCommand, retryArgs, tmuxSessionName);
139
+ retryCommand = tmux.command;
140
+ retrySpawnArgs = tmux.args;
141
+ }
142
+ const retryPty = pty.spawn(retryCommand, retrySpawnArgs, {
114
143
  name: 'xterm-256color',
115
144
  cols,
116
145
  rows,
@@ -134,14 +163,14 @@ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktre
134
163
  });
135
164
  }
136
165
  attachHandlers(ptyProcess, continueArgs.some(a => args.includes(a)));
137
- return { id, type: session.type, agent: session.agent, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, branchName: session.branchName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false };
166
+ return { id, type: session.type, agent: session.agent, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, branchName: session.branchName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false, useTmux, tmuxSessionName };
138
167
  }
139
168
  function get(id) {
140
169
  return sessions.get(id);
141
170
  }
142
171
  function list() {
143
172
  return Array.from(sessions.values())
144
- .map(({ id, type, agent, root, repoName, repoPath, worktreeName, branchName, displayName, createdAt, lastActivity, idle }) => ({
173
+ .map(({ id, type, agent, root, repoName, repoPath, worktreeName, branchName, displayName, createdAt, lastActivity, idle, useTmux, tmuxSessionName }) => ({
145
174
  id,
146
175
  type,
147
176
  agent,
@@ -154,6 +183,8 @@ function list() {
154
183
  createdAt,
155
184
  lastActivity,
156
185
  idle,
186
+ useTmux,
187
+ tmuxSessionName,
157
188
  }))
158
189
  .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
159
190
  }
@@ -170,8 +201,18 @@ function kill(id) {
170
201
  throw new Error(`Session not found: ${id}`);
171
202
  }
172
203
  session.pty.kill('SIGTERM');
204
+ if (session.tmuxSessionName) {
205
+ execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
206
+ }
173
207
  sessions.delete(id);
174
208
  }
209
+ function killAllTmuxSessions() {
210
+ for (const session of sessions.values()) {
211
+ if (session.tmuxSessionName) {
212
+ execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
213
+ }
214
+ }
215
+ }
175
216
  function resize(id, cols, rows) {
176
217
  const session = sessions.get(id);
177
218
  if (!session) {
@@ -192,4 +233,4 @@ function findRepoSession(repoPath) {
192
233
  function nextTerminalName() {
193
234
  return `Terminal ${++terminalCounter}`;
194
235
  }
195
- export { create, get, list, kill, resize, updateDisplayName, write, onIdleChange, findRepoSession, nextTerminalName, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
236
+ export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, findRepoSession, nextTerminalName, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, resolveTmuxSpawn, generateTmuxSessionName };
@@ -61,6 +61,17 @@ test('DEFAULTS has expected keys and values', () => {
61
61
  assert.equal(DEFAULTS.claudeCommand, 'claude');
62
62
  assert.deepEqual(DEFAULTS.claudeArgs, []);
63
63
  assert.equal(DEFAULTS.defaultAgent, 'claude');
64
+ assert.equal(DEFAULTS.defaultContinue, true);
65
+ assert.equal(DEFAULTS.defaultYolo, false);
66
+ assert.equal(DEFAULTS.launchInTmux, false);
67
+ });
68
+ test('loadConfig returns correct defaults for defaultContinue, defaultYolo, and launchInTmux', () => {
69
+ const configPath = path.join(tmpDir, 'config.json');
70
+ fs.writeFileSync(configPath, JSON.stringify({ port: 3456 }), 'utf8');
71
+ const config = loadConfig(configPath);
72
+ assert.equal(config.defaultContinue, true);
73
+ assert.equal(config.defaultYolo, false);
74
+ assert.equal(config.launchInTmux, false);
64
75
  });
65
76
  test('ensureMetaDir creates worktree-meta directory', () => {
66
77
  const configPath = path.join(tmpDir, 'config.json');
@@ -1,6 +1,7 @@
1
1
  import { describe, it, afterEach } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import * as sessions from '../server/sessions.js';
4
+ import { resolveTmuxSpawn, generateTmuxSessionName } from '../server/sessions.js';
4
5
  // Track created session IDs so we can clean up after each test
5
6
  const createdIds = [];
6
7
  afterEach(() => {
@@ -264,6 +265,38 @@ describe('sessions', () => {
264
265
  createdIds.push(result.id);
265
266
  assert.strictEqual(result.branchName, '');
266
267
  });
268
+ it('resolveTmuxSpawn returns correct tmux command and args', () => {
269
+ const result = resolveTmuxSpawn('claude', ['--continue'], 'test-session');
270
+ assert.deepStrictEqual(result, {
271
+ command: 'tmux',
272
+ args: ['new-session', '-s', 'test-session', '--', 'claude', '--continue'],
273
+ });
274
+ });
275
+ it('generateTmuxSessionName has crc- prefix', () => {
276
+ const name = generateTmuxSessionName('my-session', 'abcdef1234567890');
277
+ assert.ok(name.startsWith('crc-'), `expected crc- prefix, got: ${name}`);
278
+ });
279
+ it('generateTmuxSessionName sanitizes special characters', () => {
280
+ const name = generateTmuxSessionName('feat/auth-flow', 'abcdef1234567890');
281
+ assert.ok(name.startsWith('crc-feat-auth-flow-'), `expected sanitized name, got: ${name}`);
282
+ });
283
+ it('generateTmuxSessionName limits display name to 30 chars', () => {
284
+ const longName = 'a-very-long-display-name-that-exceeds-thirty-characters';
285
+ const id = 'abcdef1234567890';
286
+ const name = generateTmuxSessionName(longName, id);
287
+ // Format is crc-<sanitized up to 30>-<8 char id>
288
+ // The sanitized portion should be at most 30 chars
289
+ const withoutPrefix = name.slice('crc-'.length);
290
+ const parts = withoutPrefix.split('-');
291
+ const idPart = parts[parts.length - 1];
292
+ const displayPart = withoutPrefix.slice(0, withoutPrefix.length - idPart.length - 1);
293
+ assert.ok(displayPart.length <= 30, `display portion should be <= 30 chars, got: ${displayPart.length}`);
294
+ });
295
+ it('generateTmuxSessionName uses 8 chars from the provided id', () => {
296
+ const id = 'abcdef1234567890';
297
+ const name = generateTmuxSessionName('my-session', id);
298
+ assert.ok(name.endsWith(id.slice(0, 8)), `expected name to end with ${id.slice(0, 8)}, got: ${name}`);
299
+ });
267
300
  it('agent defaults to claude when not specified', () => {
268
301
  const result = sessions.create({
269
302
  repoName: 'test-repo',
@@ -299,4 +332,42 @@ describe('sessions', () => {
299
332
  assert.ok(session);
300
333
  assert.strictEqual(session.agent, 'codex');
301
334
  });
335
+ it('useTmux defaults to false when not specified', () => {
336
+ const result = sessions.create({
337
+ repoName: 'test-repo',
338
+ repoPath: '/tmp',
339
+ command: '/bin/echo',
340
+ args: ['hello'],
341
+ });
342
+ createdIds.push(result.id);
343
+ assert.strictEqual(result.useTmux, false);
344
+ assert.strictEqual(result.tmuxSessionName, '');
345
+ });
346
+ it('useTmux is disabled when custom command is provided even if useTmux is true', () => {
347
+ const result = sessions.create({
348
+ repoName: 'test-repo',
349
+ repoPath: '/tmp',
350
+ command: '/bin/echo',
351
+ args: ['hello'],
352
+ useTmux: true,
353
+ });
354
+ createdIds.push(result.id);
355
+ // Custom command sessions should never use tmux
356
+ assert.strictEqual(result.useTmux, false);
357
+ assert.strictEqual(result.tmuxSessionName, '');
358
+ });
359
+ it('list includes useTmux and tmuxSessionName fields', () => {
360
+ const result = sessions.create({
361
+ repoName: 'test-repo',
362
+ repoPath: '/tmp',
363
+ command: '/bin/echo',
364
+ args: ['hello'],
365
+ });
366
+ createdIds.push(result.id);
367
+ const list = sessions.list();
368
+ const session = list.find(s => s.id === result.id);
369
+ assert.ok(session);
370
+ assert.strictEqual(session.useTmux, false);
371
+ assert.strictEqual(session.tmuxSessionName, '');
372
+ });
302
373
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",