claude-remote-cli 3.6.0 → 3.8.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-BYXQcBQc.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-cJ7MQBLi.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-CiwYPknn.css">
16
16
  </head>
17
17
  <body>
@@ -1,14 +1,34 @@
1
- import bcrypt from 'bcrypt';
2
1
  import crypto from 'node:crypto';
3
- const SALT_ROUNDS = 10;
2
+ import { promisify } from 'node:util';
3
+ const scrypt = promisify(crypto.scrypt);
4
+ const SCRYPT_KEYLEN = 64;
4
5
  const MAX_ATTEMPTS = 5;
5
6
  const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
6
7
  const attemptMap = new Map();
7
8
  export async function hashPin(pin) {
8
- return bcrypt.hash(pin, SALT_ROUNDS);
9
+ const salt = crypto.randomBytes(16).toString('hex');
10
+ const derived = await scrypt(pin, salt, SCRYPT_KEYLEN);
11
+ return `scrypt:${salt}:${derived.toString('hex')}`;
9
12
  }
10
13
  export async function verifyPin(pin, hash) {
11
- return bcrypt.compare(pin, hash);
14
+ if (hash.startsWith('scrypt:')) {
15
+ const [, salt, storedHashHex] = hash.split(':');
16
+ if (!salt || !storedHashHex)
17
+ return false;
18
+ try {
19
+ const storedBuf = Buffer.from(storedHashHex, 'hex');
20
+ if (storedBuf.length !== SCRYPT_KEYLEN)
21
+ return false;
22
+ const derived = await scrypt(pin, salt, SCRYPT_KEYLEN);
23
+ return crypto.timingSafeEqual(storedBuf, derived);
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ // Legacy bcrypt hashes: require PIN reset
30
+ console.warn('[auth] Legacy bcrypt PIN hash detected. Delete pinHash from config and restart to set a new PIN.');
31
+ return false;
12
32
  }
13
33
  export function isRateLimited(ip) {
14
34
  const entry = attemptMap.get(ip);
@@ -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,25 +20,15 @@ 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);
27
- // ── Signal protection ────────────────────────────────────────────────────
28
- // Ignore SIGPIPE: piped bash commands (e.g. `cmd | grep | tail`) generate
29
- // SIGPIPE when the reading end of the pipe closes before the writer finishes.
30
- // node-pty's native module can propagate these to PTY sessions, causing
31
- // unexpected "session exited" in the browser. Ignoring SIGPIPE at the server
32
- // level prevents this cascade.
33
- process.on('SIGPIPE', () => { });
34
- // Ignore SIGHUP: if the controlling terminal disconnects (e.g. SSH drops),
35
- // keep the server and all PTY sessions alive.
36
- process.on('SIGHUP', () => { });
37
29
  // When run via CLI bin, config lives in ~/.config/claude-remote-cli/
38
30
  // When run directly (development), fall back to local config.json
39
31
  const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', '..', 'config.json');
40
- // Ensure worktree metadata directory exists alongside config
41
- ensureMetaDir(CONFIG_PATH);
42
32
  const VERSION_CACHE_TTL = 5 * 60 * 1000;
43
33
  let versionCache = null;
44
34
  function getCurrentVersion() {
@@ -46,16 +36,6 @@ function getCurrentVersion() {
46
36
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
47
37
  return pkg.version;
48
38
  }
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
39
  async function getLatestVersion() {
60
40
  const now = Date.now();
61
41
  if (versionCache && now - versionCache.fetchedAt < VERSION_CACHE_TTL) {
@@ -154,6 +134,11 @@ function ensureGitignore(repoPath, entry) {
154
134
  }
155
135
  }
156
136
  async function main() {
137
+ // Ignore SIGPIPE: node-pty can propagate pipe breaks causing unexpected session exits
138
+ process.on('SIGPIPE', () => { });
139
+ // Ignore SIGHUP: keep server alive if controlling terminal disconnects
140
+ process.on('SIGHUP', () => { });
141
+ ensureMetaDir(CONFIG_PATH);
157
142
  let config;
158
143
  try {
159
144
  config = loadConfig(CONFIG_PATH);
@@ -241,6 +226,17 @@ async function main() {
241
226
  watcher.rebuild(config.workspaces || []);
242
227
  const server = http.createServer(app);
243
228
  const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher, CONFIG_PATH);
229
+ // Configure session defaults for hooks injection
230
+ sessions.configure({ port: config.port, forceOutputParser: config.forceOutputParser ?? false });
231
+ // Mount hooks router BEFORE auth middleware — hook callbacks come from localhost Claude Code
232
+ const hooksRouter = createHooksRouter({
233
+ getSession: sessions.get,
234
+ broadcastEvent,
235
+ fireStateChange: sessions.fireStateChange,
236
+ notifySessionAttention: push.notifySessionAttention,
237
+ configPath: CONFIG_PATH,
238
+ });
239
+ app.use('/hooks', hooksRouter);
244
240
  // Mount workspace router
245
241
  const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
246
242
  app.use('/workspaces', requireAuth, workspaceRouter);
@@ -253,12 +249,16 @@ async function main() {
253
249
  }
254
250
  // Populate session metadata cache in background (non-blocking)
255
251
  populateMetaCache().catch(() => { });
256
- // Push notifications on session idle
252
+ // Push notifications on session idle (skip when hooks already sent attention notification)
257
253
  sessions.onIdleChange((sessionId, idle) => {
258
254
  if (idle) {
259
255
  const session = sessions.get(sessionId);
260
256
  if (session && session.type !== 'terminal') {
261
- push.notifySessionIdle(sessionId, session);
257
+ // Dedup: if hooks fired an attention notification within last 10s, skip
258
+ if (session.hooksActive && session.lastAttentionNotifiedAt && Date.now() - session.lastAttentionNotifiedAt < 10000) {
259
+ return;
260
+ }
261
+ push.notifySessionAttention(sessionId, session);
262
262
  }
263
263
  }
264
264
  });
@@ -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.
@@ -9,7 +9,5 @@ export class CodexOutputParser {
9
9
  onData(_chunk, _recentScrollback) {
10
10
  return null;
11
11
  }
12
- reset() {
13
- // No state to reset
14
- }
12
+ reset() { }
15
13
  }
@@ -1,10 +1,11 @@
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';
5
6
  import { AGENT_COMMANDS, AGENT_CONTINUE_ARGS } from './types.js';
6
7
  import { readMeta, writeMeta } from './config.js';
7
- import { fireSessionEnd } from './sessions.js';
8
+ import { cleanEnv } from './utils.js';
8
9
  import { outputParsers } from './output-parsers/index.js';
9
10
  const IDLE_TIMEOUT_MS = 5000;
10
11
  const MAX_SCROLLBACK = 256 * 1024; // 256KB max
@@ -23,13 +24,52 @@ export function resolveTmuxSpawn(command, args, tmuxSessionName) {
23
24
  ],
24
25
  };
25
26
  }
26
- 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;
27
+ function writeHooksSettingsFile(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
+ }
50
+ export function createPtySession(params, sessionsMap, idleChangeCallbacks, stateChangeCallbacks = [], sessionEndCallbacks = []) {
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
- // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
31
- const env = Object.assign({}, process.env);
32
- delete env.CLAUDECODE;
55
+ const env = cleanEnv();
56
+ // Inject hooks settings when spawning a real claude agent (not custom command, not forceOutputParser)
57
+ let hookToken = '';
58
+ let hooksActive = false;
59
+ let settingsPath = '';
60
+ const shouldInjectHooks = agent === 'claude' && !command && !forceOutputParser && port !== undefined;
61
+ if (shouldInjectHooks) {
62
+ hookToken = crypto.randomBytes(32).toString('hex');
63
+ try {
64
+ settingsPath = writeHooksSettingsFile(id, port, hookToken);
65
+ args = ['--settings', settingsPath, ...args];
66
+ hooksActive = true;
67
+ }
68
+ catch (err) {
69
+ console.warn(`[pty-handler] Failed to generate hooks settings for session ${id}:`, err);
70
+ hooksActive = false;
71
+ }
72
+ }
33
73
  const useTmux = !command && !!paramUseTmux;
34
74
  let spawnCommand = resolvedCommand;
35
75
  let spawnArgs = args;
@@ -78,6 +118,10 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
78
118
  needsBranchRename: false,
79
119
  agentState: 'initializing',
80
120
  outputParser: parser,
121
+ hookToken,
122
+ hooksActive,
123
+ cleanedUp: false,
124
+ _lastHookTime: undefined,
81
125
  };
82
126
  sessionsMap.set(id, session);
83
127
  // Load existing metadata to preserve a previously-set displayName
@@ -129,14 +173,39 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
129
173
  // Vendor-specific output parsing for semantic state detection
130
174
  const parseResult = session.outputParser.onData(data, scrollback.slice(-20));
131
175
  if (parseResult && parseResult.state !== session.agentState) {
132
- session.agentState = parseResult.state;
133
- for (const cb of stateChangeCallbacks)
134
- cb(session.id, parseResult.state);
176
+ if (session.hooksActive) {
177
+ // Hooks are authoritative — check 30s reconciliation timeout
178
+ const lastHook = session._lastHookTime;
179
+ const sessionAge = Date.now() - new Date(session.createdAt).getTime();
180
+ if (lastHook && Date.now() - lastHook > 30000) {
181
+ // No hook for 30s and parser disagrees — parser overrides
182
+ session.agentState = parseResult.state;
183
+ for (const cb of stateChangeCallbacks)
184
+ cb(session.id, parseResult.state);
185
+ }
186
+ else if (!lastHook && sessionAge > 30000) {
187
+ // Hooks active but never fired in 30s — allow parser to override to prevent permanent suppression
188
+ session.agentState = parseResult.state;
189
+ for (const cb of stateChangeCallbacks)
190
+ cb(session.id, parseResult.state);
191
+ }
192
+ // else: suppress parser — hooks are still fresh
193
+ }
194
+ else {
195
+ // No hooks — parser is primary (current behavior)
196
+ session.agentState = parseResult.state;
197
+ for (const cb of stateChangeCallbacks)
198
+ cb(session.id, parseResult.state);
199
+ }
135
200
  }
136
201
  });
137
202
  proc.onExit(() => {
138
203
  if (canRetry && (Date.now() - spawnTime) < 3000) {
139
- const retryArgs = args.filter(a => !continueArgs.includes(a));
204
+ let retryArgs = rawArgs.filter(a => !continueArgs.includes(a));
205
+ // Re-inject hooks settings if active (settingsPath captured from outer scope)
206
+ if (session.hooksActive && settingsPath) {
207
+ retryArgs = ['--settings', settingsPath, ...retryArgs];
208
+ }
140
209
  const retryNotice = '\r\n[claude-remote-cli] --continue not available; starting new session...\r\n';
141
210
  scrollback.length = 0;
142
211
  scrollbackBytes = 0;
@@ -178,6 +247,9 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
178
247
  attachHandlers(retryPty, false);
179
248
  return;
180
249
  }
250
+ if (session.cleanedUp)
251
+ return; // Dedup: SessionEnd hook already cleaned up
252
+ session.cleanedUp = true;
181
253
  if (restoredClearTimer)
182
254
  clearTimeout(restoredClearTimer);
183
255
  // If PTY exited and this is a restored session, mark disconnected rather than delete
@@ -197,7 +269,14 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
197
269
  if (configPath && worktreeName) {
198
270
  writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
199
271
  }
200
- fireSessionEnd(id, repoPath, session.branchName);
272
+ for (const cb of sessionEndCallbacks) {
273
+ try {
274
+ cb(id, repoPath, session.branchName);
275
+ }
276
+ catch (err) {
277
+ console.error('[pty-handler] sessionEnd callback error:', err);
278
+ }
279
+ }
201
280
  sessionsMap.delete(id);
202
281
  const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
203
282
  fs.rm(tmpDir, { recursive: true, force: true }, () => { });
@@ -58,7 +58,7 @@ function truncatePayload(payload) {
58
58
  }
59
59
  return payload.slice(0, MAX_PAYLOAD_SIZE);
60
60
  }
61
- export function notifySessionIdle(sessionId, session) {
61
+ export function notifySessionAttention(sessionId, session) {
62
62
  if (!vapidPublicKey)
63
63
  return;
64
64
  const payloadObj = {
@@ -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) {
@@ -27,51 +34,49 @@ function onSessionEnd(cb) {
27
34
  sessionEndCallbacks.push(cb);
28
35
  }
29
36
  function fireSessionEnd(sessionId, repoPath, branchName) {
30
- for (const cb of sessionEndCallbacks)
31
- cb(sessionId, repoPath, branchName);
37
+ for (const cb of sessionEndCallbacks) {
38
+ try {
39
+ cb(sessionId, repoPath, branchName);
40
+ }
41
+ catch (err) {
42
+ console.error('[sessions] sessionEnd callback error:', err);
43
+ }
44
+ }
45
+ }
46
+ export function fireStateChange(sessionId, state) {
47
+ for (const cb of stateChangeCallbacks)
48
+ cb(sessionId, state);
32
49
  }
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 }) {
50
+ function create({ id: providedId, needsBranchRename, branchRenamePrompt, agent = 'claude', cols = 80, rows = 24, args = [], port, forceOutputParser, ...rest }) {
34
51
  const id = providedId || crypto.randomBytes(8).toString('hex');
35
- // PTY path
36
52
  const ptyParams = {
53
+ ...rest,
37
54
  id,
38
- type,
39
55
  agent,
40
- repoName,
41
- repoPath,
42
- cwd,
43
- root,
44
- worktreeName,
45
- branchName,
46
- displayName,
47
- command,
48
- args,
49
56
  cols,
50
57
  rows,
51
- configPath,
52
- useTmux: paramUseTmux,
53
- tmuxSessionName: paramTmuxSessionName,
54
- initialScrollback,
55
- restored: paramRestored,
58
+ args,
59
+ port: port ?? defaultPort,
60
+ forceOutputParser: forceOutputParser ?? defaultForceOutputParser,
56
61
  };
57
- const { session: ptySession, result } = createPtySession(ptyParams, sessions, idleChangeCallbacks, stateChangeCallbacks);
62
+ const { session: ptySession, result } = createPtySession(ptyParams, sessions, idleChangeCallbacks, stateChangeCallbacks, sessionEndCallbacks);
58
63
  trackEvent({
59
64
  category: 'session',
60
65
  action: 'created',
61
66
  target: id,
62
67
  properties: {
63
68
  agent,
64
- type: type ?? 'worktree',
65
- workspace: root ?? repoPath,
66
- mode: command ? 'terminal' : 'agent',
69
+ type: rest.type ?? 'worktree',
70
+ workspace: rest.root ?? rest.repoPath,
71
+ mode: rest.command ? 'terminal' : 'agent',
67
72
  },
68
73
  session_id: id,
69
74
  });
70
- if (paramNeedsBranchRename) {
75
+ if (needsBranchRename) {
71
76
  ptySession.needsBranchRename = true;
72
77
  }
73
- if (paramBranchRenamePrompt) {
74
- ptySession.branchRenamePrompt = paramBranchRenamePrompt;
78
+ if (branchRenamePrompt) {
79
+ ptySession.branchRenamePrompt = branchRenamePrompt;
75
80
  }
76
81
  return { ...result, needsBranchRename: !!ptySession.needsBranchRename };
77
82
  }
@@ -101,6 +106,7 @@ function list() {
101
106
  status: s.status,
102
107
  needsBranchRename: !!s.needsBranchRename,
103
108
  agentState: s.agentState,
109
+ currentActivity: s.currentActivity,
104
110
  }))
105
111
  .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
106
112
  }
@@ -372,6 +378,4 @@ async function populateMetaCache() {
372
378
  }
373
379
  }));
374
380
  }
375
- // Re-export pty-handler utilities for backward compatibility
376
- 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 };
381
+ export { configure, create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, onStateChange, onSessionEnd, 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
+ }