claude-remote-cli 3.6.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/frontend/assets/{index-BYXQcBQc.js → index-cJ7MQBLi.js} +21 -21
- package/dist/frontend/index.html +1 -1
- package/dist/server/auth.js +24 -4
- package/dist/server/hooks.js +196 -0
- package/dist/server/index.js +24 -24
- package/dist/server/output-parsers/claude-parser.js +1 -1
- package/dist/server/output-parsers/codex-parser.js +1 -3
- package/dist/server/pty-handler.js +90 -11
- package/dist/server/push.js +1 -1
- package/dist/server/sessions.js +33 -29
- package/dist/server/utils.js +22 -0
- package/dist/server/workspaces.js +27 -54
- package/dist/server/ws.js +11 -115
- package/dist/test/auth.test.js +45 -2
- package/dist/test/hooks.test.js +139 -0
- package/dist/test/sessions.test.js +2 -1
- package/package.json +1 -3
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-cJ7MQBLi.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-CiwYPknn.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
package/dist/server/auth.js
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
|
-
import bcrypt from 'bcrypt';
|
|
2
1
|
import crypto from 'node:crypto';
|
|
3
|
-
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const scrypt = promisify(crypto.scrypt);
|
|
4
|
+
const SCRYPT_KEYLEN = 64;
|
|
4
5
|
const MAX_ATTEMPTS = 5;
|
|
5
6
|
const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
|
6
7
|
const attemptMap = new Map();
|
|
7
8
|
export async function hashPin(pin) {
|
|
8
|
-
|
|
9
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
10
|
+
const derived = await scrypt(pin, salt, SCRYPT_KEYLEN);
|
|
11
|
+
return `scrypt:${salt}:${derived.toString('hex')}`;
|
|
9
12
|
}
|
|
10
13
|
export async function verifyPin(pin, hash) {
|
|
11
|
-
|
|
14
|
+
if (hash.startsWith('scrypt:')) {
|
|
15
|
+
const [, salt, storedHashHex] = hash.split(':');
|
|
16
|
+
if (!salt || !storedHashHex)
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
const storedBuf = Buffer.from(storedHashHex, 'hex');
|
|
20
|
+
if (storedBuf.length !== SCRYPT_KEYLEN)
|
|
21
|
+
return false;
|
|
22
|
+
const derived = await scrypt(pin, salt, SCRYPT_KEYLEN);
|
|
23
|
+
return crypto.timingSafeEqual(storedBuf, derived);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Legacy bcrypt hashes: require PIN reset
|
|
30
|
+
console.warn('[auth] Legacy bcrypt PIN hash detected. Delete pinHash from config and restart to set a new PIN.');
|
|
31
|
+
return false;
|
|
12
32
|
}
|
|
13
33
|
export function isRateLimited(ip) {
|
|
14
34
|
const entry = attemptMap.get(ip);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
import express from 'express';
|
|
6
|
+
import { stripAnsi, cleanEnv } from './utils.js';
|
|
7
|
+
import { branchToDisplayName } from './git.js';
|
|
8
|
+
import { writeMeta } from './config.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Constants
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const LOCALHOST_ADDRS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
14
|
+
const DEFAULT_RENAME_PROMPT = 'Output ONLY a short kebab-case git branch name (no explanation, no backticks, no prefix, just the name) that describes this task:';
|
|
15
|
+
const RENAME_RETRY_DELAY_MS = 5000;
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
function setAgentState(session, state, deps) {
|
|
20
|
+
session.agentState = state;
|
|
21
|
+
deps.fireStateChange(session.id, state);
|
|
22
|
+
session._lastHookTime = Date.now();
|
|
23
|
+
}
|
|
24
|
+
function extractToolDetail(_toolName, toolInput) {
|
|
25
|
+
if (toolInput && typeof toolInput === 'object') {
|
|
26
|
+
const input = toolInput;
|
|
27
|
+
if (typeof input.file_path === 'string')
|
|
28
|
+
return input.file_path;
|
|
29
|
+
if (typeof input.path === 'string')
|
|
30
|
+
return input.path;
|
|
31
|
+
if (typeof input.command === 'string')
|
|
32
|
+
return input.command.slice(0, 80);
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
async function spawnBranchRename(session, promptText, deps) {
|
|
37
|
+
const cleanedPrompt = stripAnsi(promptText).slice(0, 500);
|
|
38
|
+
const renamePrompt = session.branchRenamePrompt ?? DEFAULT_RENAME_PROMPT;
|
|
39
|
+
const fullPrompt = renamePrompt + '\n\n' + cleanedPrompt;
|
|
40
|
+
const env = cleanEnv();
|
|
41
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
42
|
+
// Check session still exists before attempting
|
|
43
|
+
if (!deps.getSession(session.id))
|
|
44
|
+
return;
|
|
45
|
+
if (attempt > 0) {
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, RENAME_RETRY_DELAY_MS));
|
|
47
|
+
// Re-check after delay
|
|
48
|
+
if (!deps.getSession(session.id))
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const { stdout } = await execFileAsync('claude', ['-p', '--model', 'haiku', fullPrompt], { cwd: session.cwd, timeout: 30000, env });
|
|
53
|
+
// Sanitize output
|
|
54
|
+
let branchName = stdout
|
|
55
|
+
.replace(/`/g, '')
|
|
56
|
+
.replace(/[^a-zA-Z0-9-]/g, '-')
|
|
57
|
+
.replace(/-+/g, '-')
|
|
58
|
+
.replace(/^-+|-+$/g, '')
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.slice(0, 60);
|
|
61
|
+
if (!branchName)
|
|
62
|
+
continue;
|
|
63
|
+
// Check session still exists before renaming
|
|
64
|
+
if (!deps.getSession(session.id))
|
|
65
|
+
return;
|
|
66
|
+
await execFileAsync('git', ['branch', '-m', branchName], { cwd: session.cwd });
|
|
67
|
+
session.branchName = branchName;
|
|
68
|
+
session.displayName = branchToDisplayName(branchName);
|
|
69
|
+
deps.broadcastEvent('session-renamed', {
|
|
70
|
+
sessionId: session.id,
|
|
71
|
+
branchName: session.branchName,
|
|
72
|
+
displayName: session.displayName,
|
|
73
|
+
});
|
|
74
|
+
if (deps.configPath) {
|
|
75
|
+
writeMeta(deps.configPath, {
|
|
76
|
+
worktreePath: session.repoPath,
|
|
77
|
+
displayName: session.displayName,
|
|
78
|
+
lastActivity: session.lastActivity,
|
|
79
|
+
branchName: session.branchName,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return; // success
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (attempt === 1) {
|
|
86
|
+
console.error('[hooks] branch rename failed after 2 attempts:', err);
|
|
87
|
+
session.needsBranchRename = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Factory
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
export function createHooksRouter(deps) {
|
|
96
|
+
const router = Router();
|
|
97
|
+
// Middleware: IP allowlist — only localhost, do NOT trust X-Forwarded-For
|
|
98
|
+
router.use((req, res, next) => {
|
|
99
|
+
const remoteAddr = req.socket.remoteAddress;
|
|
100
|
+
if (!remoteAddr || !LOCALHOST_ADDRS.has(remoteAddr)) {
|
|
101
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
next();
|
|
105
|
+
});
|
|
106
|
+
// Middleware: parse JSON with generous limit for PostToolUse payloads
|
|
107
|
+
router.use(express.json({ limit: '5mb' }));
|
|
108
|
+
// Middleware: token verification
|
|
109
|
+
router.use((req, res, next) => {
|
|
110
|
+
const sessionId = req.query.sessionId;
|
|
111
|
+
const token = req.query.token;
|
|
112
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
113
|
+
res.status(400).json({ error: 'Missing sessionId' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (typeof token !== 'string' || !token) {
|
|
117
|
+
res.status(400).json({ error: 'Missing token' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const session = deps.getSession(sessionId);
|
|
121
|
+
if (!session) {
|
|
122
|
+
res.status(404).json({ error: 'Session not found' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const tokenBuf = Buffer.from(token);
|
|
126
|
+
const hookTokenBuf = Buffer.from(session.hookToken);
|
|
127
|
+
if (tokenBuf.length !== hookTokenBuf.length || !crypto.timingSafeEqual(tokenBuf, hookTokenBuf)) {
|
|
128
|
+
res.status(403).json({ error: 'Invalid token' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
req._hookSession = session;
|
|
132
|
+
next();
|
|
133
|
+
});
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Route handlers
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// POST /stop → idle
|
|
138
|
+
router.post('/stop', (req, res) => {
|
|
139
|
+
const session = req._hookSession;
|
|
140
|
+
setAgentState(session, 'idle', deps);
|
|
141
|
+
res.json({ ok: true });
|
|
142
|
+
});
|
|
143
|
+
// POST /notification → permission-prompt | waiting-for-input
|
|
144
|
+
router.post('/notification', (req, res) => {
|
|
145
|
+
const session = req._hookSession;
|
|
146
|
+
const type = req.query.type;
|
|
147
|
+
if (type === 'permission_prompt') {
|
|
148
|
+
setAgentState(session, 'permission-prompt', deps);
|
|
149
|
+
session.lastAttentionNotifiedAt = Date.now();
|
|
150
|
+
deps.notifySessionAttention(session.id, { displayName: session.displayName, type: session.type });
|
|
151
|
+
}
|
|
152
|
+
else if (type === 'idle_prompt') {
|
|
153
|
+
setAgentState(session, 'waiting-for-input', deps);
|
|
154
|
+
session.lastAttentionNotifiedAt = Date.now();
|
|
155
|
+
deps.notifySessionAttention(session.id, { displayName: session.displayName, type: session.type });
|
|
156
|
+
}
|
|
157
|
+
res.json({ ok: true });
|
|
158
|
+
});
|
|
159
|
+
// POST /prompt-submit → processing (+ optional branch rename on first message)
|
|
160
|
+
router.post('/prompt-submit', (req, res) => {
|
|
161
|
+
const session = req._hookSession;
|
|
162
|
+
setAgentState(session, 'processing', deps);
|
|
163
|
+
if (session.needsBranchRename === true) {
|
|
164
|
+
session.needsBranchRename = false;
|
|
165
|
+
const promptText = typeof req.body?.prompt === 'string' ? req.body.prompt : '';
|
|
166
|
+
spawnBranchRename(session, promptText, deps).catch((err) => {
|
|
167
|
+
console.error('[hooks] spawnBranchRename error:', err);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
res.json({ ok: true });
|
|
171
|
+
});
|
|
172
|
+
// POST /session-end → acknowledge hook (PTY onExit owns actual cleanup and cleanedUp flag)
|
|
173
|
+
router.post('/session-end', (_req, res) => {
|
|
174
|
+
// Acknowledge hook — PTY onExit owns actual cleanup and cleanedUp flag
|
|
175
|
+
res.json({ ok: true });
|
|
176
|
+
});
|
|
177
|
+
// POST /tool-use → set currentActivity
|
|
178
|
+
router.post('/tool-use', (req, res) => {
|
|
179
|
+
const session = req._hookSession;
|
|
180
|
+
const body = req.body;
|
|
181
|
+
const toolName = typeof body?.tool_name === 'string' ? body.tool_name : '';
|
|
182
|
+
const toolInput = body?.tool_input;
|
|
183
|
+
const detail = extractToolDetail(toolName, toolInput);
|
|
184
|
+
session.currentActivity = detail !== undefined ? { tool: toolName, detail } : { tool: toolName };
|
|
185
|
+
deps.broadcastEvent('session-activity-changed', { sessionId: session.id });
|
|
186
|
+
res.json({ ok: true });
|
|
187
|
+
});
|
|
188
|
+
// POST /tool-result → clear currentActivity
|
|
189
|
+
router.post('/tool-result', (req, res) => {
|
|
190
|
+
const session = req._hookSession;
|
|
191
|
+
session.currentActivity = undefined;
|
|
192
|
+
deps.broadcastEvent('session-activity-changed', { sessionId: session.id });
|
|
193
|
+
res.json({ ok: true });
|
|
194
|
+
});
|
|
195
|
+
return router;
|
|
196
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -20,25 +20,15 @@ import { listBranches, isBranchStale } from './git.js';
|
|
|
20
20
|
import * as push from './push.js';
|
|
21
21
|
import { initAnalytics, closeAnalytics, createAnalyticsRouter } from './analytics.js';
|
|
22
22
|
import { createWorkspaceRouter } from './workspaces.js';
|
|
23
|
+
import { createHooksRouter } from './hooks.js';
|
|
23
24
|
import { MOUNTAIN_NAMES } from './types.js';
|
|
25
|
+
import { semverLessThan } from './utils.js';
|
|
24
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
25
27
|
const __dirname = path.dirname(__filename);
|
|
26
28
|
const execFileAsync = promisify(execFile);
|
|
27
|
-
// ── Signal protection ────────────────────────────────────────────────────
|
|
28
|
-
// Ignore SIGPIPE: piped bash commands (e.g. `cmd | grep | tail`) generate
|
|
29
|
-
// SIGPIPE when the reading end of the pipe closes before the writer finishes.
|
|
30
|
-
// node-pty's native module can propagate these to PTY sessions, causing
|
|
31
|
-
// unexpected "session exited" in the browser. Ignoring SIGPIPE at the server
|
|
32
|
-
// level prevents this cascade.
|
|
33
|
-
process.on('SIGPIPE', () => { });
|
|
34
|
-
// Ignore SIGHUP: if the controlling terminal disconnects (e.g. SSH drops),
|
|
35
|
-
// keep the server and all PTY sessions alive.
|
|
36
|
-
process.on('SIGHUP', () => { });
|
|
37
29
|
// When run via CLI bin, config lives in ~/.config/claude-remote-cli/
|
|
38
30
|
// When run directly (development), fall back to local config.json
|
|
39
31
|
const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', '..', 'config.json');
|
|
40
|
-
// Ensure worktree metadata directory exists alongside config
|
|
41
|
-
ensureMetaDir(CONFIG_PATH);
|
|
42
32
|
const VERSION_CACHE_TTL = 5 * 60 * 1000;
|
|
43
33
|
let versionCache = null;
|
|
44
34
|
function getCurrentVersion() {
|
|
@@ -46,16 +36,6 @@ function getCurrentVersion() {
|
|
|
46
36
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
47
37
|
return pkg.version;
|
|
48
38
|
}
|
|
49
|
-
function semverLessThan(a, b) {
|
|
50
|
-
const parse = (v) => v.split('.').map(Number);
|
|
51
|
-
const [aMaj = 0, aMin = 0, aPat = 0] = parse(a);
|
|
52
|
-
const [bMaj = 0, bMin = 0, bPat = 0] = parse(b);
|
|
53
|
-
if (aMaj !== bMaj)
|
|
54
|
-
return aMaj < bMaj;
|
|
55
|
-
if (aMin !== bMin)
|
|
56
|
-
return aMin < bMin;
|
|
57
|
-
return aPat < bPat;
|
|
58
|
-
}
|
|
59
39
|
async function getLatestVersion() {
|
|
60
40
|
const now = Date.now();
|
|
61
41
|
if (versionCache && now - versionCache.fetchedAt < VERSION_CACHE_TTL) {
|
|
@@ -154,6 +134,11 @@ function ensureGitignore(repoPath, entry) {
|
|
|
154
134
|
}
|
|
155
135
|
}
|
|
156
136
|
async function main() {
|
|
137
|
+
// Ignore SIGPIPE: node-pty can propagate pipe breaks causing unexpected session exits
|
|
138
|
+
process.on('SIGPIPE', () => { });
|
|
139
|
+
// Ignore SIGHUP: keep server alive if controlling terminal disconnects
|
|
140
|
+
process.on('SIGHUP', () => { });
|
|
141
|
+
ensureMetaDir(CONFIG_PATH);
|
|
157
142
|
let config;
|
|
158
143
|
try {
|
|
159
144
|
config = loadConfig(CONFIG_PATH);
|
|
@@ -241,6 +226,17 @@ async function main() {
|
|
|
241
226
|
watcher.rebuild(config.workspaces || []);
|
|
242
227
|
const server = http.createServer(app);
|
|
243
228
|
const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher, CONFIG_PATH);
|
|
229
|
+
// Configure session defaults for hooks injection
|
|
230
|
+
sessions.configure({ port: config.port, forceOutputParser: config.forceOutputParser ?? false });
|
|
231
|
+
// Mount hooks router BEFORE auth middleware — hook callbacks come from localhost Claude Code
|
|
232
|
+
const hooksRouter = createHooksRouter({
|
|
233
|
+
getSession: sessions.get,
|
|
234
|
+
broadcastEvent,
|
|
235
|
+
fireStateChange: sessions.fireStateChange,
|
|
236
|
+
notifySessionAttention: push.notifySessionAttention,
|
|
237
|
+
configPath: CONFIG_PATH,
|
|
238
|
+
});
|
|
239
|
+
app.use('/hooks', hooksRouter);
|
|
244
240
|
// Mount workspace router
|
|
245
241
|
const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
|
|
246
242
|
app.use('/workspaces', requireAuth, workspaceRouter);
|
|
@@ -253,12 +249,16 @@ async function main() {
|
|
|
253
249
|
}
|
|
254
250
|
// Populate session metadata cache in background (non-blocking)
|
|
255
251
|
populateMetaCache().catch(() => { });
|
|
256
|
-
// Push notifications on session idle
|
|
252
|
+
// Push notifications on session idle (skip when hooks already sent attention notification)
|
|
257
253
|
sessions.onIdleChange((sessionId, idle) => {
|
|
258
254
|
if (idle) {
|
|
259
255
|
const session = sessions.get(sessionId);
|
|
260
256
|
if (session && session.type !== 'terminal') {
|
|
261
|
-
|
|
257
|
+
// Dedup: if hooks fired an attention notification within last 10s, skip
|
|
258
|
+
if (session.hooksActive && session.lastAttentionNotifiedAt && Date.now() - session.lastAttentionNotifiedAt < 10000) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
push.notifySessionAttention(sessionId, session);
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
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,10 +1,11 @@
|
|
|
1
1
|
import pty from 'node-pty';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { AGENT_COMMANDS, AGENT_CONTINUE_ARGS } from './types.js';
|
|
6
7
|
import { readMeta, writeMeta } from './config.js';
|
|
7
|
-
import {
|
|
8
|
+
import { cleanEnv } from './utils.js';
|
|
8
9
|
import { outputParsers } from './output-parsers/index.js';
|
|
9
10
|
const IDLE_TIMEOUT_MS = 5000;
|
|
10
11
|
const MAX_SCROLLBACK = 256 * 1024; // 256KB max
|
|
@@ -23,13 +24,52 @@ export function resolveTmuxSpawn(command, args, tmuxSessionName) {
|
|
|
23
24
|
],
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
-
const
|
|
27
|
+
function writeHooksSettingsFile(sessionId, port, token) {
|
|
28
|
+
const dir = path.join(os.tmpdir(), 'claude-remote-cli', sessionId);
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
30
|
+
const filePath = path.join(dir, 'hooks-settings.json');
|
|
31
|
+
const base = `http://127.0.0.1:${port}`;
|
|
32
|
+
const q = `sessionId=${sessionId}&token=${token}`;
|
|
33
|
+
const settings = {
|
|
34
|
+
hooks: {
|
|
35
|
+
Stop: [{ hooks: [{ type: 'http', url: `${base}/hooks/stop?${q}`, timeout: 5 }] }],
|
|
36
|
+
Notification: [
|
|
37
|
+
{ matcher: 'permission_prompt', hooks: [{ type: 'http', url: `${base}/hooks/notification?${q}&type=permission_prompt`, timeout: 5 }] },
|
|
38
|
+
{ matcher: 'idle_prompt', hooks: [{ type: 'http', url: `${base}/hooks/notification?${q}&type=idle_prompt`, timeout: 5 }] },
|
|
39
|
+
],
|
|
40
|
+
UserPromptSubmit: [{ hooks: [{ type: 'http', url: `${base}/hooks/prompt-submit?${q}`, timeout: 5 }] }],
|
|
41
|
+
SessionEnd: [{ hooks: [{ type: 'http', url: `${base}/hooks/session-end?${q}`, timeout: 5 }] }],
|
|
42
|
+
PreToolUse: [{ hooks: [{ type: 'http', url: `${base}/hooks/tool-use?${q}`, timeout: 5 }] }],
|
|
43
|
+
PostToolUse: [{ hooks: [{ type: 'http', url: `${base}/hooks/tool-result?${q}`, timeout: 5 }] }],
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
fs.writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
47
|
+
fs.chmodSync(filePath, 0o600);
|
|
48
|
+
return filePath;
|
|
49
|
+
}
|
|
50
|
+
export function createPtySession(params, sessionsMap, idleChangeCallbacks, stateChangeCallbacks = [], sessionEndCallbacks = []) {
|
|
51
|
+
const { id, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args: rawArgs = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, tmuxSessionName: paramTmuxSessionName, initialScrollback, restored: paramRestored, port, forceOutputParser, } = params;
|
|
52
|
+
let args = rawArgs;
|
|
28
53
|
const createdAt = new Date().toISOString();
|
|
29
54
|
const resolvedCommand = command || AGENT_COMMANDS[agent];
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
55
|
+
const env = cleanEnv();
|
|
56
|
+
// Inject hooks settings when spawning a real claude agent (not custom command, not forceOutputParser)
|
|
57
|
+
let hookToken = '';
|
|
58
|
+
let hooksActive = false;
|
|
59
|
+
let settingsPath = '';
|
|
60
|
+
const shouldInjectHooks = agent === 'claude' && !command && !forceOutputParser && port !== undefined;
|
|
61
|
+
if (shouldInjectHooks) {
|
|
62
|
+
hookToken = crypto.randomBytes(32).toString('hex');
|
|
63
|
+
try {
|
|
64
|
+
settingsPath = writeHooksSettingsFile(id, port, hookToken);
|
|
65
|
+
args = ['--settings', settingsPath, ...args];
|
|
66
|
+
hooksActive = true;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.warn(`[pty-handler] Failed to generate hooks settings for session ${id}:`, err);
|
|
70
|
+
hooksActive = false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
33
73
|
const useTmux = !command && !!paramUseTmux;
|
|
34
74
|
let spawnCommand = resolvedCommand;
|
|
35
75
|
let spawnArgs = args;
|
|
@@ -78,6 +118,10 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
|
|
|
78
118
|
needsBranchRename: false,
|
|
79
119
|
agentState: 'initializing',
|
|
80
120
|
outputParser: parser,
|
|
121
|
+
hookToken,
|
|
122
|
+
hooksActive,
|
|
123
|
+
cleanedUp: false,
|
|
124
|
+
_lastHookTime: undefined,
|
|
81
125
|
};
|
|
82
126
|
sessionsMap.set(id, session);
|
|
83
127
|
// Load existing metadata to preserve a previously-set displayName
|
|
@@ -129,14 +173,39 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
|
|
|
129
173
|
// Vendor-specific output parsing for semantic state detection
|
|
130
174
|
const parseResult = session.outputParser.onData(data, scrollback.slice(-20));
|
|
131
175
|
if (parseResult && parseResult.state !== session.agentState) {
|
|
132
|
-
session.
|
|
133
|
-
|
|
134
|
-
|
|
176
|
+
if (session.hooksActive) {
|
|
177
|
+
// Hooks are authoritative — check 30s reconciliation timeout
|
|
178
|
+
const lastHook = session._lastHookTime;
|
|
179
|
+
const sessionAge = Date.now() - new Date(session.createdAt).getTime();
|
|
180
|
+
if (lastHook && Date.now() - lastHook > 30000) {
|
|
181
|
+
// No hook for 30s and parser disagrees — parser overrides
|
|
182
|
+
session.agentState = parseResult.state;
|
|
183
|
+
for (const cb of stateChangeCallbacks)
|
|
184
|
+
cb(session.id, parseResult.state);
|
|
185
|
+
}
|
|
186
|
+
else if (!lastHook && sessionAge > 30000) {
|
|
187
|
+
// Hooks active but never fired in 30s — allow parser to override to prevent permanent suppression
|
|
188
|
+
session.agentState = parseResult.state;
|
|
189
|
+
for (const cb of stateChangeCallbacks)
|
|
190
|
+
cb(session.id, parseResult.state);
|
|
191
|
+
}
|
|
192
|
+
// else: suppress parser — hooks are still fresh
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// No hooks — parser is primary (current behavior)
|
|
196
|
+
session.agentState = parseResult.state;
|
|
197
|
+
for (const cb of stateChangeCallbacks)
|
|
198
|
+
cb(session.id, parseResult.state);
|
|
199
|
+
}
|
|
135
200
|
}
|
|
136
201
|
});
|
|
137
202
|
proc.onExit(() => {
|
|
138
203
|
if (canRetry && (Date.now() - spawnTime) < 3000) {
|
|
139
|
-
|
|
204
|
+
let retryArgs = rawArgs.filter(a => !continueArgs.includes(a));
|
|
205
|
+
// Re-inject hooks settings if active (settingsPath captured from outer scope)
|
|
206
|
+
if (session.hooksActive && settingsPath) {
|
|
207
|
+
retryArgs = ['--settings', settingsPath, ...retryArgs];
|
|
208
|
+
}
|
|
140
209
|
const retryNotice = '\r\n[claude-remote-cli] --continue not available; starting new session...\r\n';
|
|
141
210
|
scrollback.length = 0;
|
|
142
211
|
scrollbackBytes = 0;
|
|
@@ -178,6 +247,9 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
|
|
|
178
247
|
attachHandlers(retryPty, false);
|
|
179
248
|
return;
|
|
180
249
|
}
|
|
250
|
+
if (session.cleanedUp)
|
|
251
|
+
return; // Dedup: SessionEnd hook already cleaned up
|
|
252
|
+
session.cleanedUp = true;
|
|
181
253
|
if (restoredClearTimer)
|
|
182
254
|
clearTimeout(restoredClearTimer);
|
|
183
255
|
// If PTY exited and this is a restored session, mark disconnected rather than delete
|
|
@@ -197,7 +269,14 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
|
|
|
197
269
|
if (configPath && worktreeName) {
|
|
198
270
|
writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
|
|
199
271
|
}
|
|
200
|
-
|
|
272
|
+
for (const cb of sessionEndCallbacks) {
|
|
273
|
+
try {
|
|
274
|
+
cb(id, repoPath, session.branchName);
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
console.error('[pty-handler] sessionEnd callback error:', err);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
201
280
|
sessionsMap.delete(id);
|
|
202
281
|
const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
|
|
203
282
|
fs.rm(tmpDir, { recursive: true, force: true }, () => { });
|
package/dist/server/push.js
CHANGED
|
@@ -58,7 +58,7 @@ function truncatePayload(payload) {
|
|
|
58
58
|
}
|
|
59
59
|
return payload.slice(0, MAX_PAYLOAD_SIZE);
|
|
60
60
|
}
|
|
61
|
-
export function
|
|
61
|
+
export function notifySessionAttention(sessionId, session) {
|
|
62
62
|
if (!vapidPublicKey)
|
|
63
63
|
return;
|
|
64
64
|
const payloadObj = {
|
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) {
|
|
@@ -27,51 +34,49 @@ function onSessionEnd(cb) {
|
|
|
27
34
|
sessionEndCallbacks.push(cb);
|
|
28
35
|
}
|
|
29
36
|
function fireSessionEnd(sessionId, repoPath, branchName) {
|
|
30
|
-
for (const cb of sessionEndCallbacks)
|
|
31
|
-
|
|
37
|
+
for (const cb of sessionEndCallbacks) {
|
|
38
|
+
try {
|
|
39
|
+
cb(sessionId, repoPath, branchName);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
console.error('[sessions] sessionEnd callback error:', err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function fireStateChange(sessionId, state) {
|
|
47
|
+
for (const cb of stateChangeCallbacks)
|
|
48
|
+
cb(sessionId, state);
|
|
32
49
|
}
|
|
33
|
-
function create({ id: providedId,
|
|
50
|
+
function create({ id: providedId, needsBranchRename, branchRenamePrompt, agent = 'claude', cols = 80, rows = 24, args = [], port, forceOutputParser, ...rest }) {
|
|
34
51
|
const id = providedId || crypto.randomBytes(8).toString('hex');
|
|
35
|
-
// PTY path
|
|
36
52
|
const ptyParams = {
|
|
53
|
+
...rest,
|
|
37
54
|
id,
|
|
38
|
-
type,
|
|
39
55
|
agent,
|
|
40
|
-
repoName,
|
|
41
|
-
repoPath,
|
|
42
|
-
cwd,
|
|
43
|
-
root,
|
|
44
|
-
worktreeName,
|
|
45
|
-
branchName,
|
|
46
|
-
displayName,
|
|
47
|
-
command,
|
|
48
|
-
args,
|
|
49
56
|
cols,
|
|
50
57
|
rows,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
initialScrollback,
|
|
55
|
-
restored: paramRestored,
|
|
58
|
+
args,
|
|
59
|
+
port: port ?? defaultPort,
|
|
60
|
+
forceOutputParser: forceOutputParser ?? defaultForceOutputParser,
|
|
56
61
|
};
|
|
57
|
-
const { session: ptySession, result } = createPtySession(ptyParams, sessions, idleChangeCallbacks, stateChangeCallbacks);
|
|
62
|
+
const { session: ptySession, result } = createPtySession(ptyParams, sessions, idleChangeCallbacks, stateChangeCallbacks, sessionEndCallbacks);
|
|
58
63
|
trackEvent({
|
|
59
64
|
category: 'session',
|
|
60
65
|
action: 'created',
|
|
61
66
|
target: id,
|
|
62
67
|
properties: {
|
|
63
68
|
agent,
|
|
64
|
-
type: type ?? 'worktree',
|
|
65
|
-
workspace: root ?? repoPath,
|
|
66
|
-
mode: command ? 'terminal' : 'agent',
|
|
69
|
+
type: rest.type ?? 'worktree',
|
|
70
|
+
workspace: rest.root ?? rest.repoPath,
|
|
71
|
+
mode: rest.command ? 'terminal' : 'agent',
|
|
67
72
|
},
|
|
68
73
|
session_id: id,
|
|
69
74
|
});
|
|
70
|
-
if (
|
|
75
|
+
if (needsBranchRename) {
|
|
71
76
|
ptySession.needsBranchRename = true;
|
|
72
77
|
}
|
|
73
|
-
if (
|
|
74
|
-
ptySession.branchRenamePrompt =
|
|
78
|
+
if (branchRenamePrompt) {
|
|
79
|
+
ptySession.branchRenamePrompt = branchRenamePrompt;
|
|
75
80
|
}
|
|
76
81
|
return { ...result, needsBranchRename: !!ptySession.needsBranchRename };
|
|
77
82
|
}
|
|
@@ -101,6 +106,7 @@ function list() {
|
|
|
101
106
|
status: s.status,
|
|
102
107
|
needsBranchRename: !!s.needsBranchRename,
|
|
103
108
|
agentState: s.agentState,
|
|
109
|
+
currentActivity: s.currentActivity,
|
|
104
110
|
}))
|
|
105
111
|
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
106
112
|
}
|
|
@@ -372,6 +378,4 @@ async function populateMetaCache() {
|
|
|
372
378
|
}
|
|
373
379
|
}));
|
|
374
380
|
}
|
|
375
|
-
|
|
376
|
-
export { generateTmuxSessionName, resolveTmuxSpawn } from './pty-handler.js';
|
|
377
|
-
export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, onStateChange, onSessionEnd, fireSessionEnd, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, getSessionMeta, getAllSessionMeta, populateMetaCache, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
|
|
381
|
+
export { configure, create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, onStateChange, onSessionEnd, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, getSessionMeta, getAllSessionMeta, populateMetaCache, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Strip ANSI escape sequences (CSI, OSC, charset, mode sequences)
|
|
2
|
+
export const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b\[\?[0-9;]*[hlm]|\x1b\[[0-9]*[ABCDJKH]/g;
|
|
3
|
+
export function stripAnsi(text) {
|
|
4
|
+
return text.replace(ANSI_RE, '');
|
|
5
|
+
}
|
|
6
|
+
export function semverLessThan(a, b) {
|
|
7
|
+
const parse = (v) => (v.split('-').at(0) ?? v).split('.').map(Number);
|
|
8
|
+
const pa = parse(a);
|
|
9
|
+
const pb = parse(b);
|
|
10
|
+
const aMaj = pa[0] ?? 0, aMin = pa[1] ?? 0, aPat = pa[2] ?? 0;
|
|
11
|
+
const bMaj = pb[0] ?? 0, bMin = pb[1] ?? 0, bPat = pb[2] ?? 0;
|
|
12
|
+
if (aMaj !== bMaj)
|
|
13
|
+
return aMaj < bMaj;
|
|
14
|
+
if (aMin !== bMin)
|
|
15
|
+
return aMin < bMin;
|
|
16
|
+
return aPat < bPat;
|
|
17
|
+
}
|
|
18
|
+
export function cleanEnv() {
|
|
19
|
+
const env = Object.assign({}, process.env);
|
|
20
|
+
delete env.CLAUDECODE;
|
|
21
|
+
return env;
|
|
22
|
+
}
|