claude-remote 0.5.2 → 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.
@@ -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/cli.js CHANGED
@@ -137,6 +137,7 @@ function initConfig() {
137
137
  CWD: parsed.cwd,
138
138
  AUTH_TOKEN: authToken,
139
139
  AUTH_DISABLED: authDisabled,
140
+ ENABLE_WEB: process.env.ENABLE_WEB === '1',
140
141
  CLAUDE_EXTRA_ARGS: parsed.claudeArgs,
141
142
  DEBUG_TTY_INPUT: process.env.CLAUDE_REMOTE_DEBUG_TTY_INPUT === '1',
142
143
  blockedArgs: parsed.blocked,
@@ -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
- function createHttpServer() {
20
- return http.createServer((req, res) => {
21
- const url = req.url.split('?')[0];
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,18 +169,31 @@ function createHttpServer() {
144
169
  }
145
170
 
146
171
  // --- Static files ---
147
- const filePath = path.join(__dirname, '..', 'web', url === '/' ? 'index.html' : url);
148
- const ext = path.extname(filePath);
149
- fs.readFile(filePath, (err, data) => {
150
- if (err) {
151
- res.writeHead(404);
152
- res.end('Not found');
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');
153
188
  return;
154
189
  }
155
190
  res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
156
191
  res.end(data);
157
192
  });
158
- });
159
- }
160
-
161
- module.exports = { createHttpServer };
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
+ };