claude-remote-cli 3.6.0 → 3.7.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.
@@ -0,0 +1,196 @@
1
+ import crypto from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { Router } from 'express';
5
+ import express from 'express';
6
+ import { stripAnsi, cleanEnv } from './utils.js';
7
+ import { branchToDisplayName } from './git.js';
8
+ import { writeMeta } from './config.js';
9
+ const execFileAsync = promisify(execFile);
10
+ // ---------------------------------------------------------------------------
11
+ // Constants
12
+ // ---------------------------------------------------------------------------
13
+ const LOCALHOST_ADDRS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
14
+ const DEFAULT_RENAME_PROMPT = 'Output ONLY a short kebab-case git branch name (no explanation, no backticks, no prefix, just the name) that describes this task:';
15
+ const RENAME_RETRY_DELAY_MS = 5000;
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+ function setAgentState(session, state, deps) {
20
+ session.agentState = state;
21
+ deps.fireStateChange(session.id, state);
22
+ session._lastHookTime = Date.now();
23
+ }
24
+ function extractToolDetail(_toolName, toolInput) {
25
+ if (toolInput && typeof toolInput === 'object') {
26
+ const input = toolInput;
27
+ if (typeof input.file_path === 'string')
28
+ return input.file_path;
29
+ if (typeof input.path === 'string')
30
+ return input.path;
31
+ if (typeof input.command === 'string')
32
+ return input.command.slice(0, 80);
33
+ }
34
+ return undefined;
35
+ }
36
+ async function spawnBranchRename(session, promptText, deps) {
37
+ const cleanedPrompt = stripAnsi(promptText).slice(0, 500);
38
+ const renamePrompt = session.branchRenamePrompt ?? DEFAULT_RENAME_PROMPT;
39
+ const fullPrompt = renamePrompt + '\n\n' + cleanedPrompt;
40
+ const env = cleanEnv();
41
+ for (let attempt = 0; attempt < 2; attempt++) {
42
+ // Check session still exists before attempting
43
+ if (!deps.getSession(session.id))
44
+ return;
45
+ if (attempt > 0) {
46
+ await new Promise((resolve) => setTimeout(resolve, RENAME_RETRY_DELAY_MS));
47
+ // Re-check after delay
48
+ if (!deps.getSession(session.id))
49
+ return;
50
+ }
51
+ try {
52
+ const { stdout } = await execFileAsync('claude', ['-p', '--model', 'haiku', fullPrompt], { cwd: session.cwd, timeout: 30000, env });
53
+ // Sanitize output
54
+ let branchName = stdout
55
+ .replace(/`/g, '')
56
+ .replace(/[^a-zA-Z0-9-]/g, '-')
57
+ .replace(/-+/g, '-')
58
+ .replace(/^-+|-+$/g, '')
59
+ .toLowerCase()
60
+ .slice(0, 60);
61
+ if (!branchName)
62
+ continue;
63
+ // Check session still exists before renaming
64
+ if (!deps.getSession(session.id))
65
+ return;
66
+ await execFileAsync('git', ['branch', '-m', branchName], { cwd: session.cwd });
67
+ session.branchName = branchName;
68
+ session.displayName = branchToDisplayName(branchName);
69
+ deps.broadcastEvent('session-renamed', {
70
+ sessionId: session.id,
71
+ branchName: session.branchName,
72
+ displayName: session.displayName,
73
+ });
74
+ if (deps.configPath) {
75
+ writeMeta(deps.configPath, {
76
+ worktreePath: session.repoPath,
77
+ displayName: session.displayName,
78
+ lastActivity: session.lastActivity,
79
+ branchName: session.branchName,
80
+ });
81
+ }
82
+ return; // success
83
+ }
84
+ catch (err) {
85
+ if (attempt === 1) {
86
+ console.error('[hooks] branch rename failed after 2 attempts:', err);
87
+ session.needsBranchRename = true;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // Factory
94
+ // ---------------------------------------------------------------------------
95
+ export function createHooksRouter(deps) {
96
+ const router = Router();
97
+ // Middleware: IP allowlist — only localhost, do NOT trust X-Forwarded-For
98
+ router.use((req, res, next) => {
99
+ const remoteAddr = req.socket.remoteAddress;
100
+ if (!remoteAddr || !LOCALHOST_ADDRS.has(remoteAddr)) {
101
+ res.status(403).json({ error: 'Forbidden' });
102
+ return;
103
+ }
104
+ next();
105
+ });
106
+ // Middleware: parse JSON with generous limit for PostToolUse payloads
107
+ router.use(express.json({ limit: '5mb' }));
108
+ // Middleware: token verification
109
+ router.use((req, res, next) => {
110
+ const sessionId = req.query.sessionId;
111
+ const token = req.query.token;
112
+ if (typeof sessionId !== 'string' || !sessionId) {
113
+ res.status(400).json({ error: 'Missing sessionId' });
114
+ return;
115
+ }
116
+ if (typeof token !== 'string' || !token) {
117
+ res.status(400).json({ error: 'Missing token' });
118
+ return;
119
+ }
120
+ const session = deps.getSession(sessionId);
121
+ if (!session) {
122
+ res.status(404).json({ error: 'Session not found' });
123
+ return;
124
+ }
125
+ const tokenBuf = Buffer.from(token);
126
+ const hookTokenBuf = Buffer.from(session.hookToken);
127
+ if (tokenBuf.length !== hookTokenBuf.length || !crypto.timingSafeEqual(tokenBuf, hookTokenBuf)) {
128
+ res.status(403).json({ error: 'Invalid token' });
129
+ return;
130
+ }
131
+ req._hookSession = session;
132
+ next();
133
+ });
134
+ // ---------------------------------------------------------------------------
135
+ // Route handlers
136
+ // ---------------------------------------------------------------------------
137
+ // POST /stop → idle
138
+ router.post('/stop', (req, res) => {
139
+ const session = req._hookSession;
140
+ setAgentState(session, 'idle', deps);
141
+ res.json({ ok: true });
142
+ });
143
+ // POST /notification → permission-prompt | waiting-for-input
144
+ router.post('/notification', (req, res) => {
145
+ const session = req._hookSession;
146
+ const type = req.query.type;
147
+ if (type === 'permission_prompt') {
148
+ setAgentState(session, 'permission-prompt', deps);
149
+ session.lastAttentionNotifiedAt = Date.now();
150
+ deps.notifySessionAttention(session.id, { displayName: session.displayName, type: session.type });
151
+ }
152
+ else if (type === 'idle_prompt') {
153
+ setAgentState(session, 'waiting-for-input', deps);
154
+ session.lastAttentionNotifiedAt = Date.now();
155
+ deps.notifySessionAttention(session.id, { displayName: session.displayName, type: session.type });
156
+ }
157
+ res.json({ ok: true });
158
+ });
159
+ // POST /prompt-submit → processing (+ optional branch rename on first message)
160
+ router.post('/prompt-submit', (req, res) => {
161
+ const session = req._hookSession;
162
+ setAgentState(session, 'processing', deps);
163
+ if (session.needsBranchRename === true) {
164
+ session.needsBranchRename = false;
165
+ const promptText = typeof req.body?.prompt === 'string' ? req.body.prompt : '';
166
+ spawnBranchRename(session, promptText, deps).catch((err) => {
167
+ console.error('[hooks] spawnBranchRename error:', err);
168
+ });
169
+ }
170
+ res.json({ ok: true });
171
+ });
172
+ // POST /session-end → acknowledge hook (PTY onExit owns actual cleanup and cleanedUp flag)
173
+ router.post('/session-end', (_req, res) => {
174
+ // Acknowledge hook — PTY onExit owns actual cleanup and cleanedUp flag
175
+ res.json({ ok: true });
176
+ });
177
+ // POST /tool-use → set currentActivity
178
+ router.post('/tool-use', (req, res) => {
179
+ const session = req._hookSession;
180
+ const body = req.body;
181
+ const toolName = typeof body?.tool_name === 'string' ? body.tool_name : '';
182
+ const toolInput = body?.tool_input;
183
+ const detail = extractToolDetail(toolName, toolInput);
184
+ session.currentActivity = detail !== undefined ? { tool: toolName, detail } : { tool: toolName };
185
+ deps.broadcastEvent('session-activity-changed', { sessionId: session.id });
186
+ res.json({ ok: true });
187
+ });
188
+ // POST /tool-result → clear currentActivity
189
+ router.post('/tool-result', (req, res) => {
190
+ const session = req._hookSession;
191
+ session.currentActivity = undefined;
192
+ deps.broadcastEvent('session-activity-changed', { sessionId: session.id });
193
+ res.json({ ok: true });
194
+ });
195
+ return router;
196
+ }
@@ -20,7 +20,9 @@ import { listBranches, isBranchStale } from './git.js';
20
20
  import * as push from './push.js';
21
21
  import { initAnalytics, closeAnalytics, createAnalyticsRouter } from './analytics.js';
22
22
  import { createWorkspaceRouter } from './workspaces.js';
23
+ import { createHooksRouter } from './hooks.js';
23
24
  import { MOUNTAIN_NAMES } from './types.js';
25
+ import { semverLessThan } from './utils.js';
24
26
  const __filename = fileURLToPath(import.meta.url);
25
27
  const __dirname = path.dirname(__filename);
26
28
  const execFileAsync = promisify(execFile);
@@ -46,16 +48,6 @@ function getCurrentVersion() {
46
48
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
47
49
  return pkg.version;
48
50
  }
49
- function semverLessThan(a, b) {
50
- const parse = (v) => v.split('.').map(Number);
51
- const [aMaj = 0, aMin = 0, aPat = 0] = parse(a);
52
- const [bMaj = 0, bMin = 0, bPat = 0] = parse(b);
53
- if (aMaj !== bMaj)
54
- return aMaj < bMaj;
55
- if (aMin !== bMin)
56
- return aMin < bMin;
57
- return aPat < bPat;
58
- }
59
51
  async function getLatestVersion() {
60
52
  const now = Date.now();
61
53
  if (versionCache && now - versionCache.fetchedAt < VERSION_CACHE_TTL) {
@@ -241,6 +233,17 @@ async function main() {
241
233
  watcher.rebuild(config.workspaces || []);
242
234
  const server = http.createServer(app);
243
235
  const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher, CONFIG_PATH);
236
+ // Configure session defaults for hooks injection
237
+ sessions.configure({ port: config.port, forceOutputParser: config.forceOutputParser ?? false });
238
+ // Mount hooks router BEFORE auth middleware — hook callbacks come from localhost Claude Code
239
+ const hooksRouter = createHooksRouter({
240
+ getSession: sessions.get,
241
+ broadcastEvent,
242
+ fireStateChange: sessions.fireStateChange,
243
+ notifySessionAttention: push.notifySessionIdle,
244
+ configPath: CONFIG_PATH,
245
+ });
246
+ app.use('/hooks', hooksRouter);
244
247
  // Mount workspace router
245
248
  const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
246
249
  app.use('/workspaces', requireAuth, workspaceRouter);
@@ -253,11 +256,15 @@ async function main() {
253
256
  }
254
257
  // Populate session metadata cache in background (non-blocking)
255
258
  populateMetaCache().catch(() => { });
256
- // Push notifications on session idle
259
+ // Push notifications on session idle (skip when hooks already sent attention notification)
257
260
  sessions.onIdleChange((sessionId, idle) => {
258
261
  if (idle) {
259
262
  const session = sessions.get(sessionId);
260
263
  if (session && session.type !== 'terminal') {
264
+ // Dedup: if hooks fired an attention notification within last 10s, skip
265
+ if (session.hooksActive && session.lastAttentionNotifiedAt && Date.now() - session.lastAttentionNotifiedAt < 10000) {
266
+ return;
267
+ }
261
268
  push.notifySessionIdle(sessionId, session);
262
269
  }
263
270
  }
@@ -1,4 +1,4 @@
1
- // Strip ANSI escape sequences (CSI, OSC, charset, mode sequences)
1
+ // Duplicated from utils.ts to preserve output-parsers/ module boundary
2
2
  const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b\[\?[0-9;]*[hlm]|\x1b\[[0-9]*[ABCDJKH]/g;
3
3
  /**
4
4
  * Claude Code output parser.
@@ -1,4 +1,5 @@
1
1
  import pty from 'node-pty';
2
+ import crypto from 'node:crypto';
2
3
  import fs from 'node:fs';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
@@ -23,13 +24,54 @@ export function resolveTmuxSpawn(command, args, tmuxSessionName) {
23
24
  ],
24
25
  };
25
26
  }
27
+ export function generateHooksSettings(sessionId, port, token) {
28
+ const dir = path.join(os.tmpdir(), 'claude-remote-cli', sessionId);
29
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
30
+ const filePath = path.join(dir, 'hooks-settings.json');
31
+ const base = `http://127.0.0.1:${port}`;
32
+ const q = `sessionId=${sessionId}&token=${token}`;
33
+ const settings = {
34
+ hooks: {
35
+ Stop: [{ hooks: [{ type: 'http', url: `${base}/hooks/stop?${q}`, timeout: 5 }] }],
36
+ Notification: [
37
+ { matcher: 'permission_prompt', hooks: [{ type: 'http', url: `${base}/hooks/notification?${q}&type=permission_prompt`, timeout: 5 }] },
38
+ { matcher: 'idle_prompt', hooks: [{ type: 'http', url: `${base}/hooks/notification?${q}&type=idle_prompt`, timeout: 5 }] },
39
+ ],
40
+ UserPromptSubmit: [{ hooks: [{ type: 'http', url: `${base}/hooks/prompt-submit?${q}`, timeout: 5 }] }],
41
+ SessionEnd: [{ hooks: [{ type: 'http', url: `${base}/hooks/session-end?${q}`, timeout: 5 }] }],
42
+ PreToolUse: [{ hooks: [{ type: 'http', url: `${base}/hooks/tool-use?${q}`, timeout: 5 }] }],
43
+ PostToolUse: [{ hooks: [{ type: 'http', url: `${base}/hooks/tool-result?${q}`, timeout: 5 }] }],
44
+ },
45
+ };
46
+ fs.writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf-8');
47
+ fs.chmodSync(filePath, 0o600);
48
+ return filePath;
49
+ }
26
50
  export function createPtySession(params, sessionsMap, idleChangeCallbacks, stateChangeCallbacks = []) {
27
- 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;
51
+ const { id, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args: rawArgs = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, tmuxSessionName: paramTmuxSessionName, initialScrollback, restored: paramRestored, port, forceOutputParser, } = params;
52
+ let args = rawArgs;
28
53
  const createdAt = new Date().toISOString();
29
54
  const resolvedCommand = command || AGENT_COMMANDS[agent];
30
55
  // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
31
56
  const env = Object.assign({}, process.env);
32
57
  delete env.CLAUDECODE;
58
+ // Inject hooks settings when spawning a real claude agent (not custom command, not forceOutputParser)
59
+ let hookToken = '';
60
+ let hooksActive = false;
61
+ let settingsPath = '';
62
+ const shouldInjectHooks = agent === 'claude' && !command && !forceOutputParser && port !== undefined;
63
+ if (shouldInjectHooks) {
64
+ hookToken = crypto.randomBytes(32).toString('hex');
65
+ try {
66
+ settingsPath = generateHooksSettings(id, port, hookToken);
67
+ args = ['--settings', settingsPath, ...args];
68
+ hooksActive = true;
69
+ }
70
+ catch (err) {
71
+ console.warn(`[pty-handler] Failed to generate hooks settings for session ${id}:`, err);
72
+ hooksActive = false;
73
+ }
74
+ }
33
75
  const useTmux = !command && !!paramUseTmux;
34
76
  let spawnCommand = resolvedCommand;
35
77
  let spawnArgs = args;
@@ -78,6 +120,10 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
78
120
  needsBranchRename: false,
79
121
  agentState: 'initializing',
80
122
  outputParser: parser,
123
+ hookToken,
124
+ hooksActive,
125
+ cleanedUp: false,
126
+ _lastHookTime: undefined,
81
127
  };
82
128
  sessionsMap.set(id, session);
83
129
  // Load existing metadata to preserve a previously-set displayName
@@ -129,14 +175,39 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
129
175
  // Vendor-specific output parsing for semantic state detection
130
176
  const parseResult = session.outputParser.onData(data, scrollback.slice(-20));
131
177
  if (parseResult && parseResult.state !== session.agentState) {
132
- session.agentState = parseResult.state;
133
- for (const cb of stateChangeCallbacks)
134
- cb(session.id, parseResult.state);
178
+ if (session.hooksActive) {
179
+ // Hooks are authoritative — check 30s reconciliation timeout
180
+ const lastHook = session._lastHookTime;
181
+ const sessionAge = Date.now() - new Date(session.createdAt).getTime();
182
+ if (lastHook && Date.now() - lastHook > 30000) {
183
+ // No hook for 30s and parser disagrees — parser overrides
184
+ session.agentState = parseResult.state;
185
+ for (const cb of stateChangeCallbacks)
186
+ cb(session.id, parseResult.state);
187
+ }
188
+ else if (!lastHook && sessionAge > 30000) {
189
+ // Hooks active but never fired in 30s — allow parser to override to prevent permanent suppression
190
+ session.agentState = parseResult.state;
191
+ for (const cb of stateChangeCallbacks)
192
+ cb(session.id, parseResult.state);
193
+ }
194
+ // else: suppress parser — hooks are still fresh
195
+ }
196
+ else {
197
+ // No hooks — parser is primary (current behavior)
198
+ session.agentState = parseResult.state;
199
+ for (const cb of stateChangeCallbacks)
200
+ cb(session.id, parseResult.state);
201
+ }
135
202
  }
136
203
  });
137
204
  proc.onExit(() => {
138
205
  if (canRetry && (Date.now() - spawnTime) < 3000) {
139
- const retryArgs = args.filter(a => !continueArgs.includes(a));
206
+ let retryArgs = rawArgs.filter(a => !continueArgs.includes(a));
207
+ // Re-inject hooks settings if active (settingsPath captured from outer scope)
208
+ if (session.hooksActive && settingsPath) {
209
+ retryArgs = ['--settings', settingsPath, ...retryArgs];
210
+ }
140
211
  const retryNotice = '\r\n[claude-remote-cli] --continue not available; starting new session...\r\n';
141
212
  scrollback.length = 0;
142
213
  scrollbackBytes = 0;
@@ -178,6 +249,9 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
178
249
  attachHandlers(retryPty, false);
179
250
  return;
180
251
  }
252
+ if (session.cleanedUp)
253
+ return; // Dedup: SessionEnd hook already cleaned up
254
+ session.cleanedUp = true;
181
255
  if (restoredClearTimer)
182
256
  clearTimeout(restoredClearTimer);
183
257
  // If PTY exited and this is a restored session, mark disconnected rather than delete
@@ -13,6 +13,13 @@ const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
13
13
  const sessions = new Map();
14
14
  // Session metadata cache: session ID or worktree path -> SessionMeta
15
15
  const metaCache = new Map();
16
+ // Module-level defaults for hooks injection (set via configure())
17
+ let defaultPort;
18
+ let defaultForceOutputParser;
19
+ function configure(opts) {
20
+ defaultPort = opts.port;
21
+ defaultForceOutputParser = opts.forceOutputParser;
22
+ }
16
23
  let terminalCounter = 0;
17
24
  const idleChangeCallbacks = [];
18
25
  function onIdleChange(cb) {
@@ -30,7 +37,11 @@ function fireSessionEnd(sessionId, repoPath, branchName) {
30
37
  for (const cb of sessionEndCallbacks)
31
38
  cb(sessionId, repoPath, branchName);
32
39
  }
33
- function create({ id: providedId, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, tmuxSessionName: paramTmuxSessionName, initialScrollback, restored: paramRestored, needsBranchRename: paramNeedsBranchRename, branchRenamePrompt: paramBranchRenamePrompt }) {
40
+ export function fireStateChange(sessionId, state) {
41
+ for (const cb of stateChangeCallbacks)
42
+ cb(sessionId, state);
43
+ }
44
+ function create({ id: providedId, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, tmuxSessionName: paramTmuxSessionName, initialScrollback, restored: paramRestored, needsBranchRename: paramNeedsBranchRename, branchRenamePrompt: paramBranchRenamePrompt, port, forceOutputParser }) {
34
45
  const id = providedId || crypto.randomBytes(8).toString('hex');
35
46
  // PTY path
36
47
  const ptyParams = {
@@ -53,6 +64,8 @@ function create({ id: providedId, type, agent = 'claude', repoName, repoPath, cw
53
64
  tmuxSessionName: paramTmuxSessionName,
54
65
  initialScrollback,
55
66
  restored: paramRestored,
67
+ port: port ?? defaultPort,
68
+ forceOutputParser: forceOutputParser ?? defaultForceOutputParser,
56
69
  };
57
70
  const { session: ptySession, result } = createPtySession(ptyParams, sessions, idleChangeCallbacks, stateChangeCallbacks);
58
71
  trackEvent({
@@ -101,6 +114,7 @@ function list() {
101
114
  status: s.status,
102
115
  needsBranchRename: !!s.needsBranchRename,
103
116
  agentState: s.agentState,
117
+ currentActivity: s.currentActivity,
104
118
  }))
105
119
  .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
106
120
  }
@@ -374,4 +388,4 @@ async function populateMetaCache() {
374
388
  }
375
389
  // Re-export pty-handler utilities for backward compatibility
376
390
  export { generateTmuxSessionName, resolveTmuxSpawn } from './pty-handler.js';
377
- export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, onStateChange, onSessionEnd, fireSessionEnd, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, getSessionMeta, getAllSessionMeta, populateMetaCache, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
391
+ export { configure, create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, onStateChange, onSessionEnd, fireSessionEnd, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, getSessionMeta, getAllSessionMeta, populateMetaCache, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
@@ -0,0 +1,22 @@
1
+ // Strip ANSI escape sequences (CSI, OSC, charset, mode sequences)
2
+ export const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b\[\?[0-9;]*[hlm]|\x1b\[[0-9]*[ABCDJKH]/g;
3
+ export function stripAnsi(text) {
4
+ return text.replace(ANSI_RE, '');
5
+ }
6
+ export function semverLessThan(a, b) {
7
+ const parse = (v) => (v.split('-').at(0) ?? v).split('.').map(Number);
8
+ const pa = parse(a);
9
+ const pb = parse(b);
10
+ const aMaj = pa[0] ?? 0, aMin = pa[1] ?? 0, aPat = pa[2] ?? 0;
11
+ const bMaj = pb[0] ?? 0, bMin = pb[1] ?? 0, bPat = pb[2] ?? 0;
12
+ if (aMaj !== bMaj)
13
+ return aMaj < bMaj;
14
+ if (aMin !== bMin)
15
+ return aMin < bMin;
16
+ return aPat < bPat;
17
+ }
18
+ export function cleanEnv() {
19
+ const env = Object.assign({}, process.env);
20
+ delete env.CLAUDECODE;
21
+ return env;
22
+ }
package/dist/server/ws.js CHANGED
@@ -1,85 +1,6 @@
1
1
  import { WebSocketServer } from 'ws';
2
- import { execFile } from 'node:child_process';
3
- import { promisify } from 'node:util';
4
2
  import * as sessions from './sessions.js';
5
- import { writeMeta } from './config.js';
6
- import { branchToDisplayName } from './git.js';
7
3
  import { trackEvent } from './analytics.js';
8
- const execFileAsync = promisify(execFile);
9
- const BRANCH_POLL_INTERVAL_MS = 3000;
10
- const BRANCH_POLL_MAX_ATTEMPTS = 10;
11
- function startBranchWatcher(session, broadcastEvent, cfgPath) {
12
- const originalBranch = session.branchName;
13
- let attempts = 0;
14
- const timer = setInterval(async () => {
15
- attempts++;
16
- if (attempts > BRANCH_POLL_MAX_ATTEMPTS) {
17
- clearInterval(timer);
18
- return;
19
- }
20
- try {
21
- const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: session.cwd });
22
- const currentBranch = stdout.trim();
23
- if (currentBranch && currentBranch !== originalBranch) {
24
- clearInterval(timer);
25
- const displayName = branchToDisplayName(currentBranch);
26
- session.branchName = currentBranch;
27
- session.displayName = displayName;
28
- broadcastEvent('session-renamed', { sessionId: session.id, branchName: currentBranch, displayName });
29
- writeMeta(cfgPath, {
30
- worktreePath: session.repoPath,
31
- displayName,
32
- lastActivity: new Date().toISOString(),
33
- branchName: currentBranch,
34
- });
35
- }
36
- }
37
- catch {
38
- // git command failed — session cwd may not exist yet, retry
39
- }
40
- }, BRANCH_POLL_INTERVAL_MS);
41
- }
42
- /** Sideband branch rename: uses headless claude to generate a branch name from the first message */
43
- async function spawnBranchRename(session, firstMessage, cfgPath, broadcastEvent) {
44
- try {
45
- const cleanMessage = firstMessage.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/[\x00-\x1f]/g, ' ').trim();
46
- if (!cleanMessage)
47
- return;
48
- const basePrompt = session.branchRenamePrompt
49
- ?? `Output ONLY a short kebab-case git branch name (no explanation, no backticks, no prefix, just the name) that describes this task:`;
50
- const prompt = `${basePrompt}\n\n${cleanMessage.slice(0, 500)}`;
51
- const { stdout } = await execFileAsync('claude', ['-p', '--model', 'haiku', prompt], {
52
- cwd: session.cwd,
53
- timeout: 30000,
54
- });
55
- const branchName = stdout.trim().replace(/`/g, '').replace(/[^a-z0-9-]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase().slice(0, 60);
56
- if (!branchName)
57
- return;
58
- await execFileAsync('git', ['branch', '-m', branchName], { cwd: session.cwd });
59
- // Update session state
60
- const displayName = branchToDisplayName(branchName);
61
- session.branchName = branchName;
62
- session.displayName = displayName;
63
- broadcastEvent('session-renamed', {
64
- sessionId: session.id,
65
- branchName,
66
- displayName,
67
- });
68
- if (cfgPath) {
69
- writeMeta(cfgPath, {
70
- worktreePath: session.repoPath,
71
- displayName,
72
- lastActivity: new Date().toISOString(),
73
- branchName,
74
- });
75
- }
76
- }
77
- catch {
78
- // Sideband rename is best-effort — fall back to branch watcher if claude CLI isn't available
79
- if (cfgPath)
80
- startBranchWatcher(session, broadcastEvent, cfgPath);
81
- }
82
- }
83
4
  function parseCookies(cookieHeader) {
84
5
  const cookies = {};
85
6
  if (!cookieHeader)
@@ -94,7 +15,7 @@ function parseCookies(cookieHeader) {
94
15
  });
95
16
  return cookies;
96
17
  }
97
- function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
18
+ function setupWebSocket(server, authenticatedTokens, watcher, _configPath) {
98
19
  const wss = new WebSocketServer({ noServer: true });
99
20
  const eventClients = new Set();
100
21
  function broadcastEvent(type, data) {
@@ -185,30 +106,6 @@ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
185
106
  }
186
107
  }
187
108
  catch (_) { }
188
- // Sideband branch rename: capture first message, pass through unmodified, rename out-of-band
189
- if (ptySession.needsBranchRename && ptySession.agentState !== 'waiting-for-input') {
190
- ptySession.pty.write(str);
191
- return;
192
- }
193
- if (ptySession.needsBranchRename) {
194
- if (!ptySession._renameBuffer)
195
- ptySession._renameBuffer = '';
196
- const enterIndex = str.indexOf('\r');
197
- if (enterIndex === -1) {
198
- ptySession._renameBuffer += str;
199
- ptySession.pty.write(str); // pass through to PTY normally — user sees their typing
200
- return;
201
- }
202
- // Enter detected — pass everything through unmodified
203
- const buffered = ptySession._renameBuffer;
204
- const firstMessage = buffered + str.slice(0, enterIndex);
205
- ptySession.pty.write(str); // pass through the Enter key
206
- ptySession.needsBranchRename = false;
207
- delete ptySession._renameBuffer;
208
- // Sideband: spawn headless claude to generate branch name (async, non-blocking)
209
- spawnBranchRename(ptySession, firstMessage, configPath, broadcastEvent);
210
- return;
211
- }
212
109
  // Use ptySession.pty dynamically so writes go to current PTY
213
110
  ptySession.pty.write(str);
214
111
  });
@@ -0,0 +1,139 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { stripAnsi, semverLessThan, cleanEnv } from '../server/utils.js';
4
+ import { onStateChange, fireStateChange } from '../server/sessions.js';
5
+ describe('stripAnsi', () => {
6
+ it('strips CSI color sequences', () => {
7
+ assert.equal(stripAnsi('\x1b[32mhello\x1b[0m'), 'hello');
8
+ });
9
+ it('strips CSI bold/reset sequences', () => {
10
+ assert.equal(stripAnsi('\x1b[1mbold\x1b[0m'), 'bold');
11
+ });
12
+ it('strips OSC sequences', () => {
13
+ assert.equal(stripAnsi('\x1b]0;window title\x07plain'), 'plain');
14
+ });
15
+ it('strips cursor movement sequences', () => {
16
+ assert.equal(stripAnsi('\x1b[2Jhello'), 'hello');
17
+ });
18
+ it('preserves plain text', () => {
19
+ assert.equal(stripAnsi('hello world'), 'hello world');
20
+ });
21
+ it('handles empty string', () => {
22
+ assert.equal(stripAnsi(''), '');
23
+ });
24
+ it('strips multiple sequences in one string', () => {
25
+ assert.equal(stripAnsi('\x1b[32mfoo\x1b[0m and \x1b[1mbar\x1b[0m'), 'foo and bar');
26
+ });
27
+ });
28
+ describe('semverLessThan', () => {
29
+ it('returns true when major is lower', () => {
30
+ assert.equal(semverLessThan('1.0.0', '2.0.0'), true);
31
+ });
32
+ it('returns false when major is higher', () => {
33
+ assert.equal(semverLessThan('2.0.0', '1.0.0'), false);
34
+ });
35
+ it('returns true when patch is lower', () => {
36
+ assert.equal(semverLessThan('1.2.3', '1.2.4'), true);
37
+ });
38
+ it('returns false when patch is higher', () => {
39
+ assert.equal(semverLessThan('1.2.4', '1.2.3'), false);
40
+ });
41
+ it('returns false for equal versions', () => {
42
+ assert.equal(semverLessThan('1.0.0', '1.0.0'), false);
43
+ });
44
+ it('strips pre-release tag before comparing — 1.2.3-beta.1 vs 1.2.3 treated as equal', () => {
45
+ assert.equal(semverLessThan('1.2.3-beta.1', '1.2.3'), false);
46
+ });
47
+ it('strips pre-release tag before comparing — 1.2.3-beta.1 vs 1.3.0 treated as less than', () => {
48
+ assert.equal(semverLessThan('1.2.3-beta.1', '1.3.0'), true);
49
+ });
50
+ it('returns true when minor is lower', () => {
51
+ assert.equal(semverLessThan('1.1.0', '1.2.0'), true);
52
+ });
53
+ it('handles major version jumps', () => {
54
+ assert.equal(semverLessThan('1.9.9', '2.0.0'), true);
55
+ });
56
+ });
57
+ describe('cleanEnv', () => {
58
+ it('returns an object that does not contain CLAUDECODE', () => {
59
+ const originalValue = process.env.CLAUDECODE;
60
+ process.env.CLAUDECODE = 'some-value';
61
+ try {
62
+ const env = cleanEnv();
63
+ assert.equal(Object.prototype.hasOwnProperty.call(env, 'CLAUDECODE'), false);
64
+ }
65
+ finally {
66
+ if (originalValue === undefined) {
67
+ delete process.env.CLAUDECODE;
68
+ }
69
+ else {
70
+ process.env.CLAUDECODE = originalValue;
71
+ }
72
+ }
73
+ });
74
+ it('does not modify original process.env', () => {
75
+ const originalValue = process.env.CLAUDECODE;
76
+ process.env.CLAUDECODE = 'test-token';
77
+ try {
78
+ cleanEnv();
79
+ assert.equal(process.env.CLAUDECODE, 'test-token');
80
+ }
81
+ finally {
82
+ if (originalValue === undefined) {
83
+ delete process.env.CLAUDECODE;
84
+ }
85
+ else {
86
+ process.env.CLAUDECODE = originalValue;
87
+ }
88
+ }
89
+ });
90
+ it('returns a copy — mutations do not affect process.env', () => {
91
+ const env = cleanEnv();
92
+ const testKey = '__CRC_TEST_KEY__';
93
+ env[testKey] = 'injected';
94
+ assert.equal(process.env[testKey], undefined);
95
+ });
96
+ it('preserves other environment variables', () => {
97
+ const env = cleanEnv();
98
+ // PATH is virtually always set; verify it round-trips
99
+ if (process.env.PATH !== undefined) {
100
+ assert.equal(env.PATH, process.env.PATH);
101
+ }
102
+ });
103
+ });
104
+ describe('fireStateChange callbacks', () => {
105
+ it('calls a registered onStateChange callback with correct args', () => {
106
+ const received = [];
107
+ onStateChange((sessionId, state) => {
108
+ received.push({ sessionId, state });
109
+ });
110
+ fireStateChange('test-session-id', 'processing');
111
+ const match = received.find(e => e.sessionId === 'test-session-id' && e.state === 'processing');
112
+ assert.ok(match, 'callback should have been called with the expected sessionId and state');
113
+ });
114
+ it('fires multiple registered callbacks', () => {
115
+ let count = 0;
116
+ onStateChange(() => { count++; });
117
+ onStateChange(() => { count++; });
118
+ fireStateChange('multi-cb-session', 'idle');
119
+ assert.ok(count >= 2, 'both callbacks should have been invoked');
120
+ });
121
+ it('passes idle state to callback', () => {
122
+ let received;
123
+ onStateChange((_, state) => { received = state; });
124
+ fireStateChange('some-session', 'idle');
125
+ assert.equal(received, 'idle');
126
+ });
127
+ it('passes permission-prompt state to callback', () => {
128
+ let received;
129
+ onStateChange((_, state) => { received = state; });
130
+ fireStateChange('some-session', 'permission-prompt');
131
+ assert.equal(received, 'permission-prompt');
132
+ });
133
+ it('passes waiting-for-input state to callback', () => {
134
+ let received;
135
+ onStateChange((_, state) => { received = state; });
136
+ fireStateChange('some-session', 'waiting-for-input');
137
+ assert.equal(received, 'waiting-for-input');
138
+ });
139
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",