claude-remote 0.6.0 → 0.6.1
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/bin/claude-remote.js +1 -1
- package/hooks/bridge-session-start.js +32 -32
- package/lib/http-server.js +60 -27
- package/lib/interactive-questions.js +183 -0
- package/lib/logger.js +172 -138
- package/lib/state.js +7 -6
- package/lib/ws-server.js +132 -96
- package/package.json +1 -1
- package/server.js +18 -17
- package/web/index.html +42 -5
- package/web/modules/interactions.js +205 -86
- package/web/modules/settings.js +101 -74
- package/web/modules/state.js +2 -0
- package/web/modules/websocket.js +10 -7
- package/web/styles.css +321 -84
package/bin/claude-remote.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
require('../server.js');
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Bridge session-start hook — binds the spawned Claude session to its transcript.
|
|
3
|
-
|
|
4
|
-
const http = require('http');
|
|
5
|
-
|
|
6
|
-
if (!process.env.BRIDGE_PORT) process.exit(0);
|
|
7
|
-
|
|
8
|
-
const PORT = process.env.BRIDGE_PORT;
|
|
9
|
-
|
|
10
|
-
let input = '';
|
|
11
|
-
process.stdin.setEncoding('utf8');
|
|
12
|
-
process.stdin.on('data', chunk => (input += chunk));
|
|
13
|
-
process.stdin.on('end', () => {
|
|
14
|
-
const body = input || '{}';
|
|
15
|
-
const req = http.request({
|
|
16
|
-
hostname: '127.0.0.1',
|
|
17
|
-
port: PORT,
|
|
18
|
-
path: '/hook/session-start',
|
|
19
|
-
method: 'POST',
|
|
20
|
-
headers: {
|
|
21
|
-
'Content-Type': 'application/json',
|
|
22
|
-
'Content-Length': Buffer.byteLength(body),
|
|
23
|
-
},
|
|
24
|
-
}, () => {
|
|
25
|
-
process.exit(0);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
req.on('error', () => process.exit(0));
|
|
29
|
-
req.setTimeout(10000, () => { req.destroy(); process.exit(0); });
|
|
30
|
-
req.write(body);
|
|
31
|
-
req.end();
|
|
32
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bridge session-start hook — binds the spawned Claude session to its transcript.
|
|
3
|
+
|
|
4
|
+
const http = require('http');
|
|
5
|
+
|
|
6
|
+
if (!process.env.BRIDGE_PORT) process.exit(0);
|
|
7
|
+
|
|
8
|
+
const PORT = process.env.BRIDGE_PORT;
|
|
9
|
+
|
|
10
|
+
let input = '';
|
|
11
|
+
process.stdin.setEncoding('utf8');
|
|
12
|
+
process.stdin.on('data', chunk => (input += chunk));
|
|
13
|
+
process.stdin.on('end', () => {
|
|
14
|
+
const body = input || '{}';
|
|
15
|
+
const req = http.request({
|
|
16
|
+
hostname: '127.0.0.1',
|
|
17
|
+
port: PORT,
|
|
18
|
+
path: '/hook/session-start',
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
'Content-Length': Buffer.byteLength(body),
|
|
23
|
+
},
|
|
24
|
+
}, () => {
|
|
25
|
+
process.exit(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
req.on('error', () => process.exit(0));
|
|
29
|
+
req.setTimeout(10000, () => { req.destroy(); process.exit(0); });
|
|
30
|
+
req.write(body);
|
|
31
|
+
req.end();
|
|
32
|
+
});
|
package/lib/http-server.js
CHANGED
|
@@ -7,18 +7,43 @@ const { state, ALWAYS_AUTO_ALLOW, PARTIAL_AUTO_ALLOW } = require('./state');
|
|
|
7
7
|
const { log, broadcast, isAuthenticatedClient, setTurnState, recomputeEffectiveApprovalMode } = require('./logger');
|
|
8
8
|
const { maybeAttachHookSession, markExpectingSwitch } = require('./transcript');
|
|
9
9
|
|
|
10
|
-
const MIME = {
|
|
11
|
-
'.html': 'text/html; charset=utf-8',
|
|
12
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
13
|
-
'.css': 'text/css; charset=utf-8',
|
|
14
|
-
'.json': 'application/json',
|
|
15
|
-
'.png': 'image/png',
|
|
16
|
-
'.svg': 'image/svg+xml',
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
10
|
+
const MIME = {
|
|
11
|
+
'.html': 'text/html; charset=utf-8',
|
|
12
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
13
|
+
'.css': 'text/css; charset=utf-8',
|
|
14
|
+
'.json': 'application/json',
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.svg': 'image/svg+xml',
|
|
17
|
+
};
|
|
18
|
+
const WEB_ROOT = path.resolve(__dirname, '..', 'web');
|
|
19
|
+
|
|
20
|
+
function resolveStaticFilePath(urlPath) {
|
|
21
|
+
let decodedPath;
|
|
22
|
+
try {
|
|
23
|
+
decodedPath = decodeURIComponent(urlPath || '/');
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const relativePath = decodedPath === '/'
|
|
29
|
+
? 'index.html'
|
|
30
|
+
: decodedPath.replace(/^\/+/, '');
|
|
31
|
+
const filePath = path.resolve(WEB_ROOT, relativePath);
|
|
32
|
+
const relativeToRoot = path.relative(WEB_ROOT, filePath);
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
relativeToRoot.startsWith('..') ||
|
|
36
|
+
path.isAbsolute(relativeToRoot)
|
|
37
|
+
) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return filePath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createHttpServer() {
|
|
45
|
+
return http.createServer((req, res) => {
|
|
46
|
+
const url = req.url.split('?')[0];
|
|
22
47
|
|
|
23
48
|
// --- API: Hook approval endpoint ---
|
|
24
49
|
if (req.method === 'POST' && url === '/hook/pre-tool-use') {
|
|
@@ -144,23 +169,31 @@ function createHttpServer() {
|
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
// --- Static files ---
|
|
147
|
-
if (!state.ENABLE_WEB) {
|
|
148
|
-
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
149
|
-
res.end('Web UI disabled. Start with ENABLE_WEB=1 to enable.');
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const filePath =
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
if (!state.ENABLE_WEB) {
|
|
173
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
174
|
+
res.end('Web UI disabled. Start with ENABLE_WEB=1 to enable.');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const filePath = resolveStaticFilePath(url);
|
|
178
|
+
if (!filePath) {
|
|
179
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
180
|
+
res.end('Not found');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const ext = path.extname(filePath);
|
|
184
|
+
fs.readFile(filePath, (err, data) => {
|
|
185
|
+
if (err) {
|
|
186
|
+
res.writeHead(404);
|
|
187
|
+
res.end('Not found');
|
|
158
188
|
return;
|
|
159
189
|
}
|
|
160
190
|
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
161
191
|
res.end(data);
|
|
162
192
|
});
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
module.exports = {
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
createHttpServer,
|
|
198
|
+
resolveStaticFilePath,
|
|
199
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const CHAT_SUBMIT_DELAY_MS = 150;
|
|
4
|
+
const SINGLE_SELECT_DELAY_MS = 300;
|
|
5
|
+
const OTHER_SELECT_DELAY_MS = 500;
|
|
6
|
+
const MULTI_SELECT_TOGGLE_DELAY_MS = 200;
|
|
7
|
+
const MULTI_SELECT_OTHER_OPEN_DELAY_MS = 400;
|
|
8
|
+
const MULTI_SELECT_OTHER_CHAR_DELAY_MS = 50;
|
|
9
|
+
const MULTI_SELECT_ADVANCE_DELAY_MS = 300;
|
|
10
|
+
const FINAL_CONFIRM_DELAY_MS = 300;
|
|
11
|
+
const FINAL_CONFIRM_STEP_DELAY_MS = 100;
|
|
12
|
+
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeQuestion(question, index) {
|
|
18
|
+
const normalized = question && typeof question === 'object' ? question : {};
|
|
19
|
+
const options = Array.isArray(normalized.options) ? normalized.options : [];
|
|
20
|
+
if (options.length === 0) {
|
|
21
|
+
throw new Error(`Question ${index + 1} has no options`);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
question: String(normalized.question || '').trim(),
|
|
25
|
+
header: String(normalized.header || '').trim(),
|
|
26
|
+
options,
|
|
27
|
+
multiSelect: !!normalized.multiSelect,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeResponse(response, question, index) {
|
|
32
|
+
const raw = response && typeof response === 'object' ? response : {};
|
|
33
|
+
const otherText = typeof raw.otherText === 'string' ? raw.otherText.trim() : '';
|
|
34
|
+
const selectedOptions = Array.isArray(raw.selectedOptions)
|
|
35
|
+
? [...new Set(raw.selectedOptions
|
|
36
|
+
.map(value => Number(value))
|
|
37
|
+
.filter(value => Number.isInteger(value)))]
|
|
38
|
+
: [];
|
|
39
|
+
|
|
40
|
+
const optionCount = question.options.length;
|
|
41
|
+
for (const value of selectedOptions) {
|
|
42
|
+
if (value < 1 || value > optionCount) {
|
|
43
|
+
throw new Error(`Question ${index + 1} has an invalid option selection`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!question.multiSelect && selectedOptions.length > 1) {
|
|
48
|
+
throw new Error(`Question ${index + 1} only supports one selection`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!question.multiSelect && otherText && selectedOptions.length > 0) {
|
|
52
|
+
throw new Error(`Question ${index + 1} cannot combine custom text with option selections`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!otherText && selectedOptions.length === 0) {
|
|
56
|
+
throw new Error(`Question ${index + 1} requires an answer`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
selectedOptions: selectedOptions.sort((a, b) => a - b),
|
|
61
|
+
otherText,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeAskUserQuestionSubmission(payload) {
|
|
66
|
+
const raw = payload && typeof payload === 'object' ? payload : {};
|
|
67
|
+
const questions = Array.isArray(raw.questions) ? raw.questions : [];
|
|
68
|
+
const responses = Array.isArray(raw.responses) ? raw.responses : [];
|
|
69
|
+
if (questions.length === 0) {
|
|
70
|
+
throw new Error('Question data is missing');
|
|
71
|
+
}
|
|
72
|
+
if (questions.length !== responses.length) {
|
|
73
|
+
throw new Error('Question response count mismatch');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const normalizedQuestions = questions.map(normalizeQuestion);
|
|
77
|
+
const normalizedResponses = normalizedQuestions.map((question, index) =>
|
|
78
|
+
normalizeResponse(responses[index], question, index));
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
toolUseId: typeof raw.toolUseId === 'string' ? raw.toolUseId : '',
|
|
82
|
+
questions: normalizedQuestions,
|
|
83
|
+
responses: normalizedResponses,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildAskUserQuestionSubmissionKey(payload, fallbackId = '') {
|
|
88
|
+
const raw = payload && typeof payload === 'object' ? payload : {};
|
|
89
|
+
const toolUseId = typeof raw.toolUseId === 'string' ? raw.toolUseId.trim() : '';
|
|
90
|
+
if (toolUseId) return `tool:${toolUseId}`;
|
|
91
|
+
const fallback = String(fallbackId || '').trim();
|
|
92
|
+
return `fallback:${fallback || 'anonymous'}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function claimAskUserQuestionSubmissionLock(activeLocks, payload, fallbackId = '') {
|
|
96
|
+
if (!activeLocks || typeof activeLocks.has !== 'function' || typeof activeLocks.add !== 'function') {
|
|
97
|
+
throw new Error('Question submission lock storage unavailable');
|
|
98
|
+
}
|
|
99
|
+
const key = buildAskUserQuestionSubmissionKey(payload, fallbackId);
|
|
100
|
+
if (activeLocks.has(key)) return '';
|
|
101
|
+
activeLocks.add(key);
|
|
102
|
+
return key;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function releaseAskUserQuestionSubmissionLock(activeLocks, key) {
|
|
106
|
+
if (!activeLocks || typeof activeLocks.delete !== 'function' || !key) return;
|
|
107
|
+
activeLocks.delete(key);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildAskUserQuestionPtyOperations(payload) {
|
|
111
|
+
const normalized = normalizeAskUserQuestionSubmission(payload);
|
|
112
|
+
const operations = [];
|
|
113
|
+
|
|
114
|
+
normalized.questions.forEach((question, index) => {
|
|
115
|
+
const response = normalized.responses[index];
|
|
116
|
+
const otherOptionIndex = String(question.options.length + 1);
|
|
117
|
+
|
|
118
|
+
if (question.multiSelect) {
|
|
119
|
+
if (response.otherText) {
|
|
120
|
+
operations.push({ type: 'input', data: otherOptionIndex });
|
|
121
|
+
operations.push({ type: 'delay', ms: MULTI_SELECT_OTHER_OPEN_DELAY_MS });
|
|
122
|
+
for (const ch of response.otherText) {
|
|
123
|
+
operations.push({ type: 'input', data: ch });
|
|
124
|
+
operations.push({ type: 'delay', ms: MULTI_SELECT_OTHER_CHAR_DELAY_MS });
|
|
125
|
+
}
|
|
126
|
+
operations.push({ type: 'delay', ms: MULTI_SELECT_OTHER_OPEN_DELAY_MS });
|
|
127
|
+
}
|
|
128
|
+
for (const value of response.selectedOptions) {
|
|
129
|
+
operations.push({ type: 'input', data: String(value) });
|
|
130
|
+
operations.push({ type: 'delay', ms: MULTI_SELECT_TOGGLE_DELAY_MS });
|
|
131
|
+
}
|
|
132
|
+
operations.push({ type: 'input', data: '\x1b[C' });
|
|
133
|
+
operations.push({ type: 'delay', ms: MULTI_SELECT_ADVANCE_DELAY_MS });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (response.otherText) {
|
|
138
|
+
operations.push({ type: 'input', data: otherOptionIndex });
|
|
139
|
+
operations.push({ type: 'delay', ms: OTHER_SELECT_DELAY_MS });
|
|
140
|
+
operations.push({ type: 'text', data: response.otherText });
|
|
141
|
+
operations.push({ type: 'delay', ms: OTHER_SELECT_DELAY_MS });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
operations.push({ type: 'input', data: String(response.selectedOptions[0]) });
|
|
146
|
+
operations.push({ type: 'delay', ms: SINGLE_SELECT_DELAY_MS });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
operations.push({ type: 'delay', ms: FINAL_CONFIRM_DELAY_MS });
|
|
150
|
+
operations.push({ type: 'input', data: '\x1b[C' });
|
|
151
|
+
operations.push({ type: 'delay', ms: FINAL_CONFIRM_STEP_DELAY_MS });
|
|
152
|
+
operations.push({ type: 'input', data: '\r' });
|
|
153
|
+
|
|
154
|
+
return operations;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function executePtyOperations(proc, operations) {
|
|
158
|
+
if (!proc) throw new Error('Claude is not running');
|
|
159
|
+
for (const operation of operations) {
|
|
160
|
+
if (!proc) throw new Error('Claude is not running');
|
|
161
|
+
if (operation.type === 'delay') {
|
|
162
|
+
await sleep(operation.ms);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (operation.type === 'input') {
|
|
166
|
+
proc.write(operation.data);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (operation.type === 'text') {
|
|
170
|
+
proc.write(operation.data);
|
|
171
|
+
await sleep(CHAT_SUBMIT_DELAY_MS);
|
|
172
|
+
proc.write('\r');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
normalizeAskUserQuestionSubmission,
|
|
179
|
+
claimAskUserQuestionSubmissionLock,
|
|
180
|
+
releaseAskUserQuestionSubmissionLock,
|
|
181
|
+
buildAskUserQuestionPtyOperations,
|
|
182
|
+
executePtyOperations,
|
|
183
|
+
};
|