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/lib/logger.js
CHANGED
|
@@ -1,48 +1,81 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { WebSocket } = require('ws');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { state, LOG_FILE, EVENT_BUFFER_MAX } = require('./state');
|
|
8
|
+
const APPROVAL_MODE_ORDER = { default: 0, partial: 1, all: 2 };
|
|
9
|
+
|
|
10
|
+
// --- Logging → file only (never pollute the terminal) ---
|
|
11
|
+
let loggerFilePath = LOG_FILE;
|
|
12
|
+
let loggerInitialized = false;
|
|
13
|
+
let loggerEnabled = false;
|
|
14
|
+
|
|
15
|
+
function initLogger({ filePath = LOG_FILE, reset = true } = {}) {
|
|
16
|
+
loggerFilePath = filePath;
|
|
17
|
+
loggerInitialized = true;
|
|
18
|
+
try {
|
|
19
|
+
fs.mkdirSync(path.dirname(loggerFilePath), { recursive: true });
|
|
20
|
+
const header = `--- Bridge started ${new Date().toISOString()} ---\n`;
|
|
21
|
+
if (reset) {
|
|
22
|
+
fs.writeFileSync(loggerFilePath, header);
|
|
23
|
+
} else {
|
|
24
|
+
fs.appendFileSync(loggerFilePath, header);
|
|
25
|
+
}
|
|
26
|
+
loggerEnabled = true;
|
|
27
|
+
} catch {
|
|
28
|
+
loggerEnabled = false;
|
|
29
|
+
}
|
|
30
|
+
return loggerEnabled;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensureLoggerReady() {
|
|
34
|
+
if (loggerInitialized) return loggerEnabled;
|
|
35
|
+
return initLogger({ filePath: loggerFilePath, reset: false });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function log(msg) {
|
|
39
|
+
if (!ensureLoggerReady()) return false;
|
|
40
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
41
|
+
try {
|
|
42
|
+
fs.appendFileSync(loggerFilePath, line);
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
loggerEnabled = false;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function wsLabel(ws) {
|
|
51
|
+
const clientId = ws && ws._clientInstanceId ? ` client=${ws._clientInstanceId}` : '';
|
|
52
|
+
return `ws#${ws && ws._bridgeId ? ws._bridgeId : '?'}${clientId}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isAuthenticatedClient(ws) {
|
|
56
|
+
return !!ws && ws.readyState === WebSocket.OPEN && !!ws._authenticated;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeApprovalMode(mode) {
|
|
60
|
+
const normalized = String(mode || '').toLowerCase();
|
|
61
|
+
return Object.prototype.hasOwnProperty.call(APPROVAL_MODE_ORDER, normalized) ? normalized : 'default';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function computeConnectedHighestApprovalMode() {
|
|
65
|
+
if (!state.wss) return 'default';
|
|
66
|
+
let best = 'default';
|
|
67
|
+
let bestScore = APPROVAL_MODE_ORDER.default;
|
|
68
|
+
for (const ws of state.wss.clients) {
|
|
69
|
+
if (!isAuthenticatedClient(ws)) continue;
|
|
70
|
+
const mode = normalizeApprovalMode(ws._approvalMode);
|
|
71
|
+
const score = APPROVAL_MODE_ORDER[mode];
|
|
72
|
+
if (score > bestScore) {
|
|
73
|
+
best = mode;
|
|
74
|
+
bestScore = score;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
46
79
|
|
|
47
80
|
function sendWs(ws, msg, context = '') {
|
|
48
81
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
@@ -59,8 +92,8 @@ function sendWs(ws, msg, context = '') {
|
|
|
59
92
|
return true;
|
|
60
93
|
}
|
|
61
94
|
|
|
62
|
-
function broadcast(msg) {
|
|
63
|
-
if (!state.wss) return;
|
|
95
|
+
function broadcast(msg) {
|
|
96
|
+
if (!state.wss) return;
|
|
64
97
|
const raw = JSON.stringify(msg);
|
|
65
98
|
const recipients = [];
|
|
66
99
|
for (const ws of state.wss.clients) {
|
|
@@ -71,62 +104,62 @@ function broadcast(msg) {
|
|
|
71
104
|
}
|
|
72
105
|
if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'turn_state') {
|
|
73
106
|
log(`Broadcast ${msg.type} -> ${recipients.length} client(s)${recipients.length ? ` [${recipients.join(', ')}]` : ''}`);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function autoResolveAllPendingApprovals(reason = '') {
|
|
78
|
-
if (state.pendingApprovals.size === 0) return;
|
|
79
|
-
for (const [id, approval] of state.pendingApprovals) {
|
|
80
|
-
clearTimeout(approval.timer);
|
|
81
|
-
approval.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
82
|
-
approval.res.end(JSON.stringify({ decision: 'allow' }));
|
|
83
|
-
log(`Permission #${id}: auto-allowed (${reason || 'effective mode switched to all'})`);
|
|
84
|
-
}
|
|
85
|
-
state.pendingApprovals.clear();
|
|
86
|
-
broadcast({ type: 'clear_permissions' });
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function recomputeEffectiveApprovalMode(reason = '') {
|
|
90
|
-
const connectedHighest = computeConnectedHighestApprovalMode();
|
|
91
|
-
const connectedScore = APPROVAL_MODE_ORDER[connectedHighest];
|
|
92
|
-
const turnFloor = normalizeApprovalMode(state.turnApprovalFloorMode);
|
|
93
|
-
const turnFloorScore = APPROVAL_MODE_ORDER[turnFloor];
|
|
94
|
-
const floorActive = state.turnState.phase === 'running' && !!state.turnApprovalFloorMode;
|
|
95
|
-
const nextMode = (floorActive && turnFloorScore > connectedScore) ? turnFloor : connectedHighest;
|
|
96
|
-
if (state.approvalMode === nextMode) return nextMode;
|
|
97
|
-
|
|
98
|
-
const prevMode = state.approvalMode;
|
|
99
|
-
state.approvalMode = nextMode;
|
|
100
|
-
log(`Approval mode effective: ${prevMode} -> ${nextMode}${reason ? ` (${reason})` : ''} connected=${connectedHighest} turnFloor=${floorActive ? turnFloor : 'none'} phase=${state.turnState.phase}`);
|
|
101
|
-
if (nextMode === 'all' && prevMode !== 'all') {
|
|
102
|
-
autoResolveAllPendingApprovals(reason || 'effective mode switched to all');
|
|
103
|
-
}
|
|
104
|
-
return nextMode;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function setClientApprovalMode(ws, mode, reason = '') {
|
|
108
|
-
if (!ws) return state.approvalMode;
|
|
109
|
-
const normalized = normalizeApprovalMode(mode);
|
|
110
|
-
ws._approvalMode = normalized;
|
|
111
|
-
log(`Approval mode reported by ${wsLabel(ws)}: ${normalized}${reason ? ` (${reason})` : ''}`);
|
|
112
|
-
return recomputeEffectiveApprovalMode(`client mode update ${wsLabel(ws)}`);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function setTurnApprovalFloorMode(mode, reason = '') {
|
|
116
|
-
const normalized = normalizeApprovalMode(mode);
|
|
117
|
-
const prev = state.turnApprovalFloorMode || 'none';
|
|
118
|
-
state.turnApprovalFloorMode = normalized;
|
|
119
|
-
log(`Turn approval floor set: ${prev} -> ${normalized}${reason ? ` (${reason})` : ''}`);
|
|
120
|
-
return normalized;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function clearTurnApprovalFloorMode(reason = '') {
|
|
124
|
-
if (!state.turnApprovalFloorMode) return state.approvalMode;
|
|
125
|
-
const prev = state.turnApprovalFloorMode;
|
|
126
|
-
state.turnApprovalFloorMode = '';
|
|
127
|
-
log(`Turn approval floor cleared: ${prev}${reason ? ` (${reason})` : ''}`);
|
|
128
|
-
return recomputeEffectiveApprovalMode(`turn floor cleared${reason ? `: ${reason}` : ''}`);
|
|
129
|
-
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function autoResolveAllPendingApprovals(reason = '') {
|
|
111
|
+
if (state.pendingApprovals.size === 0) return;
|
|
112
|
+
for (const [id, approval] of state.pendingApprovals) {
|
|
113
|
+
clearTimeout(approval.timer);
|
|
114
|
+
approval.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
115
|
+
approval.res.end(JSON.stringify({ decision: 'allow' }));
|
|
116
|
+
log(`Permission #${id}: auto-allowed (${reason || 'effective mode switched to all'})`);
|
|
117
|
+
}
|
|
118
|
+
state.pendingApprovals.clear();
|
|
119
|
+
broadcast({ type: 'clear_permissions' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function recomputeEffectiveApprovalMode(reason = '') {
|
|
123
|
+
const connectedHighest = computeConnectedHighestApprovalMode();
|
|
124
|
+
const connectedScore = APPROVAL_MODE_ORDER[connectedHighest];
|
|
125
|
+
const turnFloor = normalizeApprovalMode(state.turnApprovalFloorMode);
|
|
126
|
+
const turnFloorScore = APPROVAL_MODE_ORDER[turnFloor];
|
|
127
|
+
const floorActive = state.turnState.phase === 'running' && !!state.turnApprovalFloorMode;
|
|
128
|
+
const nextMode = (floorActive && turnFloorScore > connectedScore) ? turnFloor : connectedHighest;
|
|
129
|
+
if (state.approvalMode === nextMode) return nextMode;
|
|
130
|
+
|
|
131
|
+
const prevMode = state.approvalMode;
|
|
132
|
+
state.approvalMode = nextMode;
|
|
133
|
+
log(`Approval mode effective: ${prevMode} -> ${nextMode}${reason ? ` (${reason})` : ''} connected=${connectedHighest} turnFloor=${floorActive ? turnFloor : 'none'} phase=${state.turnState.phase}`);
|
|
134
|
+
if (nextMode === 'all' && prevMode !== 'all') {
|
|
135
|
+
autoResolveAllPendingApprovals(reason || 'effective mode switched to all');
|
|
136
|
+
}
|
|
137
|
+
return nextMode;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function setClientApprovalMode(ws, mode, reason = '') {
|
|
141
|
+
if (!ws) return state.approvalMode;
|
|
142
|
+
const normalized = normalizeApprovalMode(mode);
|
|
143
|
+
ws._approvalMode = normalized;
|
|
144
|
+
log(`Approval mode reported by ${wsLabel(ws)}: ${normalized}${reason ? ` (${reason})` : ''}`);
|
|
145
|
+
return recomputeEffectiveApprovalMode(`client mode update ${wsLabel(ws)}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function setTurnApprovalFloorMode(mode, reason = '') {
|
|
149
|
+
const normalized = normalizeApprovalMode(mode);
|
|
150
|
+
const prev = state.turnApprovalFloorMode || 'none';
|
|
151
|
+
state.turnApprovalFloorMode = normalized;
|
|
152
|
+
log(`Turn approval floor set: ${prev} -> ${normalized}${reason ? ` (${reason})` : ''}`);
|
|
153
|
+
return normalized;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function clearTurnApprovalFloorMode(reason = '') {
|
|
157
|
+
if (!state.turnApprovalFloorMode) return state.approvalMode;
|
|
158
|
+
const prev = state.turnApprovalFloorMode;
|
|
159
|
+
state.turnApprovalFloorMode = '';
|
|
160
|
+
log(`Turn approval floor cleared: ${prev}${reason ? ` (${reason})` : ''}`);
|
|
161
|
+
return recomputeEffectiveApprovalMode(`turn floor cleared${reason ? `: ${reason}` : ''}`);
|
|
162
|
+
}
|
|
130
163
|
|
|
131
164
|
function latestEventSeq() {
|
|
132
165
|
return state.eventBuffer.length > 0 ? state.eventBuffer[state.eventBuffer.length - 1].seq : 0;
|
|
@@ -147,34 +180,34 @@ function sendTurnState(ws, context = '') {
|
|
|
147
180
|
return sendWs(ws, getTurnStatePayload(), context);
|
|
148
181
|
}
|
|
149
182
|
|
|
150
|
-
function setTurnState(phase, { sessionId = state.currentSessionId, reason = '', force = false } = {}) {
|
|
151
|
-
const normalizedPhase = phase === 'running' ? 'running' : 'idle';
|
|
152
|
-
const normalizedSessionId = sessionId || null;
|
|
183
|
+
function setTurnState(phase, { sessionId = state.currentSessionId, reason = '', force = false } = {}) {
|
|
184
|
+
const normalizedPhase = phase === 'running' ? 'running' : 'idle';
|
|
185
|
+
const normalizedSessionId = sessionId || null;
|
|
153
186
|
const changed = force ||
|
|
154
187
|
state.turnState.phase !== normalizedPhase ||
|
|
155
188
|
state.turnState.sessionId !== normalizedSessionId;
|
|
156
189
|
|
|
157
190
|
if (!changed) return false;
|
|
158
191
|
|
|
159
|
-
state.turnState = {
|
|
160
|
-
phase: normalizedPhase,
|
|
161
|
-
sessionId: normalizedSessionId,
|
|
162
|
-
version: ++state.turnStateVersion,
|
|
163
|
-
updatedAt: Date.now(),
|
|
164
|
-
reason,
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const modeReason = reason || `turn_state:${normalizedPhase}`;
|
|
168
|
-
if (normalizedPhase !== 'running' && state.turnApprovalFloorMode) {
|
|
169
|
-
clearTurnApprovalFloorMode(modeReason);
|
|
170
|
-
} else {
|
|
171
|
-
recomputeEffectiveApprovalMode(modeReason);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
log(`Turn state -> phase=${state.turnState.phase} session=${state.turnState.sessionId ?? 'null'} version=${state.turnState.version}${reason ? ` reason=${reason}` : ''}`);
|
|
175
|
-
broadcast(getTurnStatePayload());
|
|
176
|
-
return true;
|
|
177
|
-
}
|
|
192
|
+
state.turnState = {
|
|
193
|
+
phase: normalizedPhase,
|
|
194
|
+
sessionId: normalizedSessionId,
|
|
195
|
+
version: ++state.turnStateVersion,
|
|
196
|
+
updatedAt: Date.now(),
|
|
197
|
+
reason,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const modeReason = reason || `turn_state:${normalizedPhase}`;
|
|
201
|
+
if (normalizedPhase !== 'running' && state.turnApprovalFloorMode) {
|
|
202
|
+
clearTurnApprovalFloorMode(modeReason);
|
|
203
|
+
} else {
|
|
204
|
+
recomputeEffectiveApprovalMode(modeReason);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
log(`Turn state -> phase=${state.turnState.phase} session=${state.turnState.sessionId ?? 'null'} version=${state.turnState.version}${reason ? ` reason=${reason}` : ''}`);
|
|
208
|
+
broadcast(getTurnStatePayload());
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
178
211
|
|
|
179
212
|
function emitInterrupt(source) {
|
|
180
213
|
const interruptEvent = {
|
|
@@ -197,20 +230,21 @@ function formatTtyInputChunk(chunk) {
|
|
|
197
230
|
return `len=${buf.length} hex=${buf.toString('hex')} base64=${buf.toString('base64')} utf8=${JSON.stringify(buf.toString('utf8'))}`;
|
|
198
231
|
}
|
|
199
232
|
|
|
200
|
-
module.exports = {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
233
|
+
module.exports = {
|
|
234
|
+
initLogger,
|
|
235
|
+
log,
|
|
236
|
+
wsLabel,
|
|
237
|
+
isAuthenticatedClient,
|
|
204
238
|
sendWs,
|
|
205
239
|
broadcast,
|
|
206
240
|
latestEventSeq,
|
|
207
241
|
getTurnStatePayload,
|
|
208
|
-
sendTurnState,
|
|
209
|
-
setTurnState,
|
|
210
|
-
recomputeEffectiveApprovalMode,
|
|
211
|
-
setClientApprovalMode,
|
|
212
|
-
setTurnApprovalFloorMode,
|
|
213
|
-
clearTurnApprovalFloorMode,
|
|
214
|
-
emitInterrupt,
|
|
215
|
-
formatTtyInputChunk,
|
|
216
|
-
};
|
|
242
|
+
sendTurnState,
|
|
243
|
+
setTurnState,
|
|
244
|
+
recomputeEffectiveApprovalMode,
|
|
245
|
+
setClientApprovalMode,
|
|
246
|
+
setTurnApprovalFloorMode,
|
|
247
|
+
clearTurnApprovalFloorMode,
|
|
248
|
+
emitInterrupt,
|
|
249
|
+
formatTtyInputChunk,
|
|
250
|
+
};
|
package/lib/state.js
CHANGED
|
@@ -76,12 +76,13 @@ const state = {
|
|
|
76
76
|
wss: null,
|
|
77
77
|
nextWsId: 0,
|
|
78
78
|
|
|
79
|
-
// Permission approval
|
|
80
|
-
approvalSeq: 0,
|
|
81
|
-
pendingApprovals: new Map(),
|
|
82
|
-
pendingImageUploads: new Map(),
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
// Permission approval
|
|
80
|
+
approvalSeq: 0,
|
|
81
|
+
pendingApprovals: new Map(),
|
|
82
|
+
pendingImageUploads: new Map(),
|
|
83
|
+
pendingQuestionSubmissions: new Set(),
|
|
84
|
+
approvalMode: 'default',
|
|
85
|
+
turnApprovalFloorMode: '',
|
|
85
86
|
|
|
86
87
|
// TTY forwarders
|
|
87
88
|
ttyInputForwarderAttached: false,
|
package/lib/ws-server.js
CHANGED
|
@@ -13,19 +13,19 @@ const {
|
|
|
13
13
|
LEGACY_REPLAY_DELAY_MS,
|
|
14
14
|
isTTY,
|
|
15
15
|
} = require('./state');
|
|
16
|
-
const {
|
|
17
|
-
log,
|
|
18
|
-
broadcast,
|
|
19
|
-
wsLabel,
|
|
20
|
-
sendWs,
|
|
21
|
-
sendTurnState,
|
|
22
|
-
setTurnState,
|
|
23
|
-
recomputeEffectiveApprovalMode,
|
|
24
|
-
setClientApprovalMode,
|
|
25
|
-
setTurnApprovalFloorMode,
|
|
26
|
-
latestEventSeq,
|
|
27
|
-
emitInterrupt,
|
|
28
|
-
} = require('./logger');
|
|
16
|
+
const {
|
|
17
|
+
log,
|
|
18
|
+
broadcast,
|
|
19
|
+
wsLabel,
|
|
20
|
+
sendWs,
|
|
21
|
+
sendTurnState,
|
|
22
|
+
setTurnState,
|
|
23
|
+
recomputeEffectiveApprovalMode,
|
|
24
|
+
setClientApprovalMode,
|
|
25
|
+
setTurnApprovalFloorMode,
|
|
26
|
+
latestEventSeq,
|
|
27
|
+
emitInterrupt,
|
|
28
|
+
} = require('./logger');
|
|
29
29
|
const {
|
|
30
30
|
extractSlashCommand,
|
|
31
31
|
markExpectingSwitch,
|
|
@@ -34,17 +34,23 @@ const {
|
|
|
34
34
|
getDirectoryRoots,
|
|
35
35
|
assertDirectoryPath,
|
|
36
36
|
} = require('./transcript');
|
|
37
|
-
const {
|
|
38
|
-
cleanupImageUpload,
|
|
39
|
-
cleanupClientUploads,
|
|
40
|
-
sendUploadStatus,
|
|
41
|
-
handlePreparedImageUpload,
|
|
42
|
-
handleImageUpload,
|
|
43
|
-
createTempImageFile,
|
|
44
|
-
} = require('./image-upload');
|
|
45
|
-
const {
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
const {
|
|
38
|
+
cleanupImageUpload,
|
|
39
|
+
cleanupClientUploads,
|
|
40
|
+
sendUploadStatus,
|
|
41
|
+
handlePreparedImageUpload,
|
|
42
|
+
handleImageUpload,
|
|
43
|
+
createTempImageFile,
|
|
44
|
+
} = require('./image-upload');
|
|
45
|
+
const {
|
|
46
|
+
claimAskUserQuestionSubmissionLock,
|
|
47
|
+
releaseAskUserQuestionSubmissionLock,
|
|
48
|
+
buildAskUserQuestionPtyOperations,
|
|
49
|
+
executePtyOperations,
|
|
50
|
+
} = require('./interactive-questions');
|
|
51
|
+
const { restartClaude } = require('./pty-manager');
|
|
52
|
+
|
|
53
|
+
function sendReplay(ws, lastSeq = null) {
|
|
48
54
|
const normalizedLastSeq = Number.isInteger(lastSeq) && lastSeq >= 0 ? lastSeq : null;
|
|
49
55
|
const replayFrom = normalizedLastSeq == null ? 0 : normalizedLastSeq;
|
|
50
56
|
const records = replayFrom > 0
|
|
@@ -101,11 +107,11 @@ function setupWebSocketServer(server) {
|
|
|
101
107
|
const wss = new WebSocketServer({ server });
|
|
102
108
|
state.wss = wss;
|
|
103
109
|
|
|
104
|
-
wss.on('connection', (ws, req) => {
|
|
110
|
+
wss.on('connection', (ws, req) => {
|
|
105
111
|
ws._bridgeId = ++state.nextWsId;
|
|
106
112
|
ws._clientInstanceId = '';
|
|
107
|
-
ws._authenticated = state.AUTH_DISABLED;
|
|
108
|
-
ws._approvalMode = 'default';
|
|
113
|
+
ws._authenticated = state.AUTH_DISABLED;
|
|
114
|
+
ws._approvalMode = 'default';
|
|
109
115
|
ws._authTimer = null;
|
|
110
116
|
log(`WS connected: ${wsLabel(ws)} remote=${req.socket.remoteAddress || '?'} ua=${JSON.stringify(req.headers['user-agent'] || '')} authRequired=${!state.AUTH_DISABLED}`);
|
|
111
117
|
|
|
@@ -226,10 +232,40 @@ function setupWebSocketServer(server) {
|
|
|
226
232
|
log(`Foreground probe ack -> ${wsLabel(ws)} probeId=${probeId || 'none'} session=${state.currentSessionId ?? 'null'} lastSeq=${latestEventSeq()}`);
|
|
227
233
|
break;
|
|
228
234
|
}
|
|
229
|
-
case 'input':
|
|
230
|
-
if (state.claudeProc) state.claudeProc.write(msg.data);
|
|
231
|
-
break;
|
|
232
|
-
case '
|
|
235
|
+
case 'input':
|
|
236
|
+
if (state.claudeProc) state.claudeProc.write(msg.data);
|
|
237
|
+
break;
|
|
238
|
+
case 'answer_questions': {
|
|
239
|
+
const submissionKey = claimAskUserQuestionSubmissionLock(
|
|
240
|
+
state.pendingQuestionSubmissions,
|
|
241
|
+
msg,
|
|
242
|
+
wsLabel(ws),
|
|
243
|
+
);
|
|
244
|
+
if (!submissionKey) {
|
|
245
|
+
log(`AskUserQuestion duplicate submit ignored from ${wsLabel(ws)} toolUseId=${msg.toolUseId || 'unknown'}`);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
if (!state.claudeProc) {
|
|
250
|
+
throw new Error('Claude is not running');
|
|
251
|
+
}
|
|
252
|
+
const operations = buildAskUserQuestionPtyOperations(msg);
|
|
253
|
+
log(`AskUserQuestion submit from ${wsLabel(ws)} toolUseId=${msg.toolUseId || 'unknown'} questions=${Array.isArray(msg.questions) ? msg.questions.length : 0}`);
|
|
254
|
+
await executePtyOperations(state.claudeProc, operations);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const error = err && err.message ? err.message : 'Failed to submit answers';
|
|
257
|
+
log(`AskUserQuestion submit error from ${wsLabel(ws)} toolUseId=${msg.toolUseId || 'unknown'}: ${error}`);
|
|
258
|
+
sendWs(ws, {
|
|
259
|
+
type: 'question_submission_error',
|
|
260
|
+
toolUseId: typeof msg.toolUseId === 'string' ? msg.toolUseId : '',
|
|
261
|
+
error,
|
|
262
|
+
}, 'answer_questions_error');
|
|
263
|
+
} finally {
|
|
264
|
+
releaseAskUserQuestionSubmissionLock(state.pendingQuestionSubmissions, submissionKey);
|
|
265
|
+
}
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
case 'interrupt': {
|
|
233
269
|
if (!state.claudeProc || state.turnState.phase !== 'running') break;
|
|
234
270
|
log(`Interrupt from ${wsLabel(ws)} — sending Ctrl+C to PTY`);
|
|
235
271
|
state.claudeProc.write('\x03');
|
|
@@ -239,23 +275,23 @@ function setupWebSocketServer(server) {
|
|
|
239
275
|
case 'expect_clear':
|
|
240
276
|
markExpectingSwitch();
|
|
241
277
|
break;
|
|
242
|
-
case 'chat':
|
|
243
|
-
if (state.claudeProc) {
|
|
244
|
-
const text = msg.text;
|
|
245
|
-
log(`Chat input → PTY: "${text.substring(0, 80)}"`);
|
|
246
|
-
const slashCommand = extractSlashCommand(text);
|
|
247
|
-
if (slashCommand === '/clear') {
|
|
248
|
-
markExpectingSwitch();
|
|
249
|
-
}
|
|
250
|
-
if (!slashCommand) {
|
|
251
|
-
if (state.turnState.phase !== 'running') {
|
|
252
|
-
setTurnApprovalFloorMode(ws._approvalMode || 'default', `chat by ${wsLabel(ws)}`);
|
|
253
|
-
}
|
|
254
|
-
setTurnState('running', { reason: 'chat' });
|
|
255
|
-
}
|
|
256
|
-
state.claudeProc.write(text);
|
|
257
|
-
setTimeout(() => {
|
|
258
|
-
if (state.claudeProc) state.claudeProc.write('\r');
|
|
278
|
+
case 'chat':
|
|
279
|
+
if (state.claudeProc) {
|
|
280
|
+
const text = msg.text;
|
|
281
|
+
log(`Chat input → PTY: "${text.substring(0, 80)}"`);
|
|
282
|
+
const slashCommand = extractSlashCommand(text);
|
|
283
|
+
if (slashCommand === '/clear') {
|
|
284
|
+
markExpectingSwitch();
|
|
285
|
+
}
|
|
286
|
+
if (!slashCommand) {
|
|
287
|
+
if (state.turnState.phase !== 'running') {
|
|
288
|
+
setTurnApprovalFloorMode(ws._approvalMode || 'default', `chat by ${wsLabel(ws)}`);
|
|
289
|
+
}
|
|
290
|
+
setTurnState('running', { reason: 'chat' });
|
|
291
|
+
}
|
|
292
|
+
state.claudeProc.write(text);
|
|
293
|
+
setTimeout(() => {
|
|
294
|
+
if (state.claudeProc) state.claudeProc.write('\r');
|
|
259
295
|
}, 150);
|
|
260
296
|
}
|
|
261
297
|
break;
|
|
@@ -264,29 +300,29 @@ function setupWebSocketServer(server) {
|
|
|
264
300
|
state.claudeProc.resize(msg.cols, msg.rows);
|
|
265
301
|
}
|
|
266
302
|
break;
|
|
267
|
-
case 'permission_response': {
|
|
268
|
-
const approval = state.pendingApprovals.get(msg.id);
|
|
269
|
-
if (approval) {
|
|
270
|
-
clearTimeout(approval.timer);
|
|
271
|
-
state.pendingApprovals.delete(msg.id);
|
|
303
|
+
case 'permission_response': {
|
|
304
|
+
const approval = state.pendingApprovals.get(msg.id);
|
|
305
|
+
if (approval) {
|
|
306
|
+
clearTimeout(approval.timer);
|
|
307
|
+
state.pendingApprovals.delete(msg.id);
|
|
272
308
|
approval.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
273
309
|
approval.res.end(JSON.stringify({
|
|
274
310
|
decision: msg.decision,
|
|
275
311
|
reason: msg.reason || '',
|
|
276
|
-
}));
|
|
277
|
-
log(`Permission #${msg.id}: ${msg.decision}`);
|
|
278
|
-
broadcast({
|
|
279
|
-
type: 'permission_resolved',
|
|
280
|
-
id: msg.id,
|
|
281
|
-
decision: msg.decision,
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
break;
|
|
285
|
-
}
|
|
286
|
-
case 'set_approval_mode': {
|
|
287
|
-
setClientApprovalMode(ws, msg.mode);
|
|
288
|
-
break;
|
|
289
|
-
}
|
|
312
|
+
}));
|
|
313
|
+
log(`Permission #${msg.id}: ${msg.decision}`);
|
|
314
|
+
broadcast({
|
|
315
|
+
type: 'permission_resolved',
|
|
316
|
+
id: msg.id,
|
|
317
|
+
decision: msg.decision,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case 'set_approval_mode': {
|
|
323
|
+
setClientApprovalMode(ws, msg.mode);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
290
326
|
case 'image_upload_init': {
|
|
291
327
|
const uploadId = String(msg.uploadId || '');
|
|
292
328
|
if (!uploadId) {
|
|
@@ -388,7 +424,7 @@ function setupWebSocketServer(server) {
|
|
|
388
424
|
sendUploadStatus(ws, uploadId, 'aborted');
|
|
389
425
|
break;
|
|
390
426
|
}
|
|
391
|
-
case 'image_submit': {
|
|
427
|
+
case 'image_submit': {
|
|
392
428
|
const uploadId = String(msg.uploadId || '');
|
|
393
429
|
const upload = state.pendingImageUploads.get(uploadId);
|
|
394
430
|
if (!upload || !upload.tmpFile) {
|
|
@@ -396,33 +432,33 @@ function setupWebSocketServer(server) {
|
|
|
396
432
|
break;
|
|
397
433
|
}
|
|
398
434
|
try {
|
|
399
|
-
await handlePreparedImageUpload({
|
|
400
|
-
tmpFile: upload.tmpFile,
|
|
435
|
+
await handlePreparedImageUpload({
|
|
436
|
+
tmpFile: upload.tmpFile,
|
|
401
437
|
mediaType: upload.mediaType,
|
|
402
438
|
text: msg.text || '',
|
|
403
439
|
logLabel: upload.name || uploadId,
|
|
404
440
|
onCleanup: () => cleanupImageUpload(uploadId),
|
|
405
|
-
});
|
|
406
|
-
upload.submitted = true;
|
|
407
|
-
upload.updatedAt = Date.now();
|
|
408
|
-
if (state.turnState.phase !== 'running') {
|
|
409
|
-
setTurnApprovalFloorMode(ws._approvalMode || 'default', `image_submit by ${wsLabel(ws)}`);
|
|
410
|
-
}
|
|
411
|
-
setTurnState('running', { reason: 'image_submit' });
|
|
412
|
-
sendUploadStatus(ws, uploadId, 'submitted');
|
|
413
|
-
} catch (err) {
|
|
414
|
-
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
415
|
-
cleanupImageUpload(uploadId);
|
|
416
|
-
}
|
|
417
|
-
break;
|
|
418
|
-
}
|
|
419
|
-
case 'image_upload': {
|
|
420
|
-
if (state.turnState.phase !== 'running') {
|
|
421
|
-
setTurnApprovalFloorMode(ws._approvalMode || 'default', `legacy image_upload by ${wsLabel(ws)}`);
|
|
441
|
+
});
|
|
442
|
+
upload.submitted = true;
|
|
443
|
+
upload.updatedAt = Date.now();
|
|
444
|
+
if (state.turnState.phase !== 'running') {
|
|
445
|
+
setTurnApprovalFloorMode(ws._approvalMode || 'default', `image_submit by ${wsLabel(ws)}`);
|
|
446
|
+
}
|
|
447
|
+
setTurnState('running', { reason: 'image_submit' });
|
|
448
|
+
sendUploadStatus(ws, uploadId, 'submitted');
|
|
449
|
+
} catch (err) {
|
|
450
|
+
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
451
|
+
cleanupImageUpload(uploadId);
|
|
422
452
|
}
|
|
423
|
-
handleImageUpload(msg);
|
|
424
453
|
break;
|
|
425
454
|
}
|
|
455
|
+
case 'image_upload': {
|
|
456
|
+
if (state.turnState.phase !== 'running') {
|
|
457
|
+
setTurnApprovalFloorMode(ws._approvalMode || 'default', `legacy image_upload by ${wsLabel(ws)}`);
|
|
458
|
+
}
|
|
459
|
+
handleImageUpload(msg);
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
426
462
|
case 'list_sessions': {
|
|
427
463
|
try {
|
|
428
464
|
const sessions = scanSessions(state.CWD, 20);
|
|
@@ -475,7 +511,7 @@ function setupWebSocketServer(server) {
|
|
|
475
511
|
}
|
|
476
512
|
});
|
|
477
513
|
|
|
478
|
-
ws.on('close', () => {
|
|
514
|
+
ws.on('close', () => {
|
|
479
515
|
if (ws._authTimer) {
|
|
480
516
|
clearTimeout(ws._authTimer);
|
|
481
517
|
ws._authTimer = null;
|
|
@@ -483,12 +519,12 @@ function setupWebSocketServer(server) {
|
|
|
483
519
|
if (ws._legacyReplayTimer) {
|
|
484
520
|
clearTimeout(ws._legacyReplayTimer);
|
|
485
521
|
ws._legacyReplayTimer = null;
|
|
486
|
-
}
|
|
487
|
-
log(`WS closed: ${wsLabel(ws)}`);
|
|
488
|
-
cleanupClientUploads(ws);
|
|
489
|
-
recomputeEffectiveApprovalMode(`client disconnected ${wsLabel(ws)}`);
|
|
490
|
-
});
|
|
491
|
-
});
|
|
492
|
-
}
|
|
522
|
+
}
|
|
523
|
+
log(`WS closed: ${wsLabel(ws)}`);
|
|
524
|
+
cleanupClientUploads(ws);
|
|
525
|
+
recomputeEffectiveApprovalMode(`client disconnected ${wsLabel(ws)}`);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
}
|
|
493
529
|
|
|
494
530
|
module.exports = { setupWebSocketServer };
|