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.
- package/dist/server/hooks.js +196 -0
- package/dist/server/index.js +18 -11
- package/dist/server/output-parsers/claude-parser.js +1 -1
- package/dist/server/pty-handler.js +79 -5
- package/dist/server/sessions.js +16 -2
- package/dist/server/utils.js +22 -0
- package/dist/server/ws.js +1 -104
- package/dist/test/hooks.test.js +139 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
package/dist/server/sessions.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
+
});
|