claude-remote-cli 3.5.4 → 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.
@@ -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-QZrLSCSL.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-BYXQcBQc.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-CiwYPknn.css">
16
16
  </head>
17
17
  <body>
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import Database from 'better-sqlite3';
4
+ import { Router } from 'express';
5
+ let db = null;
6
+ let insertStmt = null;
7
+ const SCHEMA = `
8
+ CREATE TABLE IF NOT EXISTS events (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
11
+ category TEXT NOT NULL, -- 'session', 'ui', 'agent', 'navigation', 'workspace'
12
+ action TEXT NOT NULL,
13
+ target TEXT,
14
+ properties TEXT,
15
+ session_id TEXT,
16
+ device TEXT
17
+ );
18
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
19
+ CREATE INDEX IF NOT EXISTS idx_events_category_action ON events(category, action);
20
+ CREATE INDEX IF NOT EXISTS idx_events_target ON events(target);
21
+ `;
22
+ const INSERT_SQL = 'INSERT INTO events (category, action, target, properties, session_id, device) VALUES (?, ?, ?, ?, ?, ?)';
23
+ export function initAnalytics(configDir) {
24
+ if (db) {
25
+ db.close();
26
+ db = null;
27
+ insertStmt = null;
28
+ }
29
+ const dbPath = path.join(configDir, 'analytics.db');
30
+ db = new Database(dbPath);
31
+ db.pragma('journal_mode = WAL');
32
+ db.exec(SCHEMA);
33
+ insertStmt = db.prepare(INSERT_SQL);
34
+ }
35
+ export function closeAnalytics() {
36
+ if (db) {
37
+ db.close();
38
+ db = null;
39
+ insertStmt = null;
40
+ }
41
+ }
42
+ function runInsert(stmt, event) {
43
+ stmt.run(event.category, event.action, event.target ?? null, event.properties ? JSON.stringify(event.properties) : null, event.session_id ?? null, event.device ?? null);
44
+ }
45
+ export function trackEvent(event) {
46
+ if (!insertStmt)
47
+ return;
48
+ try {
49
+ runInsert(insertStmt, event);
50
+ }
51
+ catch {
52
+ // Analytics write failure is non-fatal
53
+ }
54
+ }
55
+ export function getDbPath(configDir) {
56
+ return path.join(configDir, 'analytics.db');
57
+ }
58
+ export function getDbSize(configDir) {
59
+ try {
60
+ return fs.statSync(getDbPath(configDir)).size;
61
+ }
62
+ catch {
63
+ return 0;
64
+ }
65
+ }
66
+ export function createAnalyticsRouter(configDir) {
67
+ const router = Router();
68
+ // POST /analytics/events — batch ingest from frontend
69
+ router.post('/events', (req, res) => {
70
+ const { events } = req.body;
71
+ if (!Array.isArray(events)) {
72
+ res.status(400).json({ error: 'events array required' });
73
+ return;
74
+ }
75
+ if (!db || !insertStmt) {
76
+ res.status(503).json({ error: 'Analytics not initialized' });
77
+ return;
78
+ }
79
+ const stmt = insertStmt;
80
+ const insertMany = db.transaction((evts) => {
81
+ let inserted = 0;
82
+ for (const evt of evts) {
83
+ if (!evt.category || !evt.action)
84
+ continue;
85
+ runInsert(stmt, evt);
86
+ inserted++;
87
+ }
88
+ return inserted;
89
+ });
90
+ try {
91
+ const inserted = insertMany(events);
92
+ res.json({ ok: true, count: inserted });
93
+ }
94
+ catch {
95
+ res.status(500).json({ error: 'Failed to write events' });
96
+ }
97
+ });
98
+ // GET /analytics/size — DB file size in bytes
99
+ router.get('/size', (_req, res) => {
100
+ res.json({ bytes: getDbSize(configDir) });
101
+ });
102
+ // DELETE /analytics/events — truncate events table
103
+ router.delete('/events', (_req, res) => {
104
+ if (!db) {
105
+ res.status(503).json({ error: 'Analytics not initialized' });
106
+ return;
107
+ }
108
+ try {
109
+ db.exec('DELETE FROM events');
110
+ try {
111
+ db.pragma('wal_checkpoint(TRUNCATE)');
112
+ }
113
+ catch { /* best-effort */ }
114
+ res.json({ ok: true });
115
+ }
116
+ catch {
117
+ res.status(500).json({ error: 'Failed to clear analytics' });
118
+ }
119
+ });
120
+ return router;
121
+ }
@@ -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
+ }
@@ -18,8 +18,11 @@ import { isInstalled as serviceIsInstalled } from './service.js';
18
18
  import { extensionForMime, setClipboardImage } from './clipboard.js';
19
19
  import { listBranches, isBranchStale } from './git.js';
20
20
  import * as push from './push.js';
21
+ import { initAnalytics, closeAnalytics, createAnalyticsRouter } from './analytics.js';
21
22
  import { createWorkspaceRouter } from './workspaces.js';
23
+ import { createHooksRouter } from './hooks.js';
22
24
  import { MOUNTAIN_NAMES } from './types.js';
25
+ import { semverLessThan } from './utils.js';
23
26
  const __filename = fileURLToPath(import.meta.url);
24
27
  const __dirname = path.dirname(__filename);
25
28
  const execFileAsync = promisify(execFile);
@@ -45,16 +48,6 @@ function getCurrentVersion() {
45
48
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
46
49
  return pkg.version;
47
50
  }
48
- function semverLessThan(a, b) {
49
- const parse = (v) => v.split('.').map(Number);
50
- const [aMaj = 0, aMin = 0, aPat = 0] = parse(a);
51
- const [bMaj = 0, bMin = 0, bPat = 0] = parse(b);
52
- if (aMaj !== bMaj)
53
- return aMaj < bMaj;
54
- if (aMin !== bMin)
55
- return aMin < bMin;
56
- return aPat < bPat;
57
- }
58
51
  async function getLatestVersion() {
59
52
  const now = Date.now();
60
53
  if (versionCache && now - versionCache.fetchedAt < VERSION_CACHE_TTL) {
@@ -167,6 +160,13 @@ async function main() {
167
160
  if (process.env.CLAUDE_REMOTE_HOST)
168
161
  config.host = process.env.CLAUDE_REMOTE_HOST;
169
162
  push.ensureVapidKeys(config, CONFIG_PATH, saveConfig);
163
+ const configDir = path.dirname(CONFIG_PATH);
164
+ try {
165
+ initAnalytics(configDir);
166
+ }
167
+ catch (err) {
168
+ console.warn('Analytics disabled: failed to initialize:', err instanceof Error ? err.message : err);
169
+ }
170
170
  if (!config.pinHash) {
171
171
  const pin = await promptPin('Set up a PIN for claude-remote-cli:');
172
172
  config.pinHash = await auth.hashPin(pin);
@@ -233,22 +233,38 @@ async function main() {
233
233
  watcher.rebuild(config.workspaces || []);
234
234
  const server = http.createServer(app);
235
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);
236
247
  // Mount workspace router
237
248
  const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
238
249
  app.use('/workspaces', requireAuth, workspaceRouter);
250
+ // Mount analytics router
251
+ app.use('/analytics', requireAuth, createAnalyticsRouter(configDir));
239
252
  // Restore sessions from a previous update restart
240
- const configDir = path.dirname(CONFIG_PATH);
241
253
  const restoredCount = await restoreFromDisk(configDir);
242
254
  if (restoredCount > 0) {
243
255
  console.log(`Restored ${restoredCount} session(s) from previous update.`);
244
256
  }
245
257
  // Populate session metadata cache in background (non-blocking)
246
258
  populateMetaCache().catch(() => { });
247
- // Push notifications on session idle
259
+ // Push notifications on session idle (skip when hooks already sent attention notification)
248
260
  sessions.onIdleChange((sessionId, idle) => {
249
261
  if (idle) {
250
262
  const session = sessions.get(sessionId);
251
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
+ }
252
268
  push.notifySessionIdle(sessionId, session);
253
269
  }
254
270
  }
@@ -920,6 +936,7 @@ async function main() {
920
936
  // tmux not installed or no sessions — ignore
921
937
  }
922
938
  function gracefulShutdown() {
939
+ closeAnalytics();
923
940
  server.close();
924
941
  // Serialize sessions to disk BEFORE killing them
925
942
  const configDir = path.dirname(CONFIG_PATH);
@@ -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
@@ -6,12 +6,20 @@ import { promisify } from 'node:util';
6
6
  import { AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS } from './types.js';
7
7
  import { createPtySession } from './pty-handler.js';
8
8
  import { getPrForBranch, getWorkingTreeDiff } from './git.js';
9
+ import { trackEvent } from './analytics.js';
9
10
  const execFileAsync = promisify(execFile);
10
11
  const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
11
12
  // In-memory registry: id -> Session
12
13
  const sessions = new Map();
13
14
  // Session metadata cache: session ID or worktree path -> SessionMeta
14
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
+ }
15
23
  let terminalCounter = 0;
16
24
  const idleChangeCallbacks = [];
17
25
  function onIdleChange(cb) {
@@ -29,7 +37,11 @@ function fireSessionEnd(sessionId, repoPath, branchName) {
29
37
  for (const cb of sessionEndCallbacks)
30
38
  cb(sessionId, repoPath, branchName);
31
39
  }
32
- 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 }) {
33
45
  const id = providedId || crypto.randomBytes(8).toString('hex');
34
46
  // PTY path
35
47
  const ptyParams = {
@@ -52,8 +64,22 @@ function create({ id: providedId, type, agent = 'claude', repoName, repoPath, cw
52
64
  tmuxSessionName: paramTmuxSessionName,
53
65
  initialScrollback,
54
66
  restored: paramRestored,
67
+ port: port ?? defaultPort,
68
+ forceOutputParser: forceOutputParser ?? defaultForceOutputParser,
55
69
  };
56
70
  const { session: ptySession, result } = createPtySession(ptyParams, sessions, idleChangeCallbacks, stateChangeCallbacks);
71
+ trackEvent({
72
+ category: 'session',
73
+ action: 'created',
74
+ target: id,
75
+ properties: {
76
+ agent,
77
+ type: type ?? 'worktree',
78
+ workspace: root ?? repoPath,
79
+ mode: command ? 'terminal' : 'agent',
80
+ },
81
+ session_id: id,
82
+ });
57
83
  if (paramNeedsBranchRename) {
58
84
  ptySession.needsBranchRename = true;
59
85
  }
@@ -88,6 +114,7 @@ function list() {
88
114
  status: s.status,
89
115
  needsBranchRename: !!s.needsBranchRename,
90
116
  agentState: s.agentState,
117
+ currentActivity: s.currentActivity,
91
118
  }))
92
119
  .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
93
120
  }
@@ -112,6 +139,19 @@ function kill(id) {
112
139
  if (session.tmuxSessionName) {
113
140
  execFile('tmux', ['kill-session', '-t', session.tmuxSessionName], () => { });
114
141
  }
142
+ const durationS = Math.round((Date.now() - new Date(session.createdAt).getTime()) / 1000);
143
+ trackEvent({
144
+ category: 'session',
145
+ action: 'ended',
146
+ target: id,
147
+ properties: {
148
+ agent: session.agent,
149
+ type: session.type,
150
+ workspace: session.root || session.repoPath,
151
+ duration_s: durationS,
152
+ },
153
+ session_id: id,
154
+ });
115
155
  fireSessionEnd(id, session.repoPath, session.branchName);
116
156
  sessions.delete(id);
117
157
  }
@@ -348,4 +388,4 @@ async function populateMetaCache() {
348
388
  }
349
389
  // Re-export pty-handler utilities for backward compatibility
350
390
  export { generateTmuxSessionName, resolveTmuxSpawn } from './pty-handler.js';
351
- 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
+ }