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.
- package/dist/frontend/assets/index-BYXQcBQc.js +50 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server/analytics.js +121 -0
- package/dist/server/hooks.js +196 -0
- package/dist/server/index.js +29 -12
- package/dist/server/output-parsers/claude-parser.js +1 -1
- package/dist/server/pty-handler.js +79 -5
- package/dist/server/sessions.js +42 -2
- package/dist/server/utils.js +22 -0
- package/dist/server/workspaces.js +3 -0
- package/dist/server/ws.js +8 -104
- package/dist/test/analytics.test.js +152 -0
- package/dist/test/hooks.test.js +139 -0
- package/package.json +3 -1
- package/dist/frontend/assets/index-QZrLSCSL.js +0 -50
package/dist/frontend/index.html
CHANGED
|
@@ -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-
|
|
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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
@@ -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
|
|
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
|
+
}
|