claude-remote 0.5.1 → 0.5.2

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/lib/logger.js ADDED
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { WebSocket } = require('ws');
5
+ const crypto = require('crypto');
6
+ const { state, LOG_FILE, EVENT_BUFFER_MAX } = require('./state');
7
+ const APPROVAL_MODE_ORDER = { default: 0, partial: 1, all: 2 };
8
+
9
+ // --- Logging → file only (never pollute the terminal) ---
10
+ fs.writeFileSync(LOG_FILE, `--- Bridge started ${new Date().toISOString()} ---\n`);
11
+
12
+ function log(msg) {
13
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
14
+ fs.appendFileSync(LOG_FILE, line);
15
+ }
16
+
17
+ function wsLabel(ws) {
18
+ const clientId = ws && ws._clientInstanceId ? ` client=${ws._clientInstanceId}` : '';
19
+ return `ws#${ws && ws._bridgeId ? ws._bridgeId : '?'}${clientId}`;
20
+ }
21
+
22
+ function isAuthenticatedClient(ws) {
23
+ return !!ws && ws.readyState === WebSocket.OPEN && !!ws._authenticated;
24
+ }
25
+
26
+ function normalizeApprovalMode(mode) {
27
+ const normalized = String(mode || '').toLowerCase();
28
+ return Object.prototype.hasOwnProperty.call(APPROVAL_MODE_ORDER, normalized) ? normalized : 'default';
29
+ }
30
+
31
+ function computeConnectedHighestApprovalMode() {
32
+ if (!state.wss) return 'default';
33
+ let best = 'default';
34
+ let bestScore = APPROVAL_MODE_ORDER.default;
35
+ for (const ws of state.wss.clients) {
36
+ if (!isAuthenticatedClient(ws)) continue;
37
+ const mode = normalizeApprovalMode(ws._approvalMode);
38
+ const score = APPROVAL_MODE_ORDER[mode];
39
+ if (score > bestScore) {
40
+ best = mode;
41
+ bestScore = score;
42
+ }
43
+ }
44
+ return best;
45
+ }
46
+
47
+ function sendWs(ws, msg, context = '') {
48
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
49
+ ws.send(JSON.stringify(msg));
50
+ if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'replay_done' || msg.type === 'turn_state') {
51
+ const extra = [];
52
+ if (msg.sessionId !== undefined) extra.push(`session=${msg.sessionId ?? 'null'}`);
53
+ if (msg.lastSeq !== undefined) extra.push(`lastSeq=${msg.lastSeq}`);
54
+ if (msg.resumed !== undefined) extra.push(`resumed=${msg.resumed}`);
55
+ if (msg.phase !== undefined) extra.push(`phase=${msg.phase}`);
56
+ if (msg.version !== undefined) extra.push(`version=${msg.version}`);
57
+ log(`Send ${msg.type}${context ? ` (${context})` : ''} -> ${wsLabel(ws)}${extra.length ? ` ${extra.join(' ')}` : ''}`);
58
+ }
59
+ return true;
60
+ }
61
+
62
+ function broadcast(msg) {
63
+ if (!state.wss) return;
64
+ const raw = JSON.stringify(msg);
65
+ const recipients = [];
66
+ for (const ws of state.wss.clients) {
67
+ if (isAuthenticatedClient(ws)) {
68
+ ws.send(raw);
69
+ recipients.push(wsLabel(ws));
70
+ }
71
+ }
72
+ if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'turn_state') {
73
+ 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
+ }
130
+
131
+ function latestEventSeq() {
132
+ return state.eventBuffer.length > 0 ? state.eventBuffer[state.eventBuffer.length - 1].seq : 0;
133
+ }
134
+
135
+ function getTurnStatePayload() {
136
+ return {
137
+ type: 'turn_state',
138
+ phase: state.turnState.phase,
139
+ sessionId: state.turnState.sessionId,
140
+ version: state.turnState.version,
141
+ updatedAt: state.turnState.updatedAt,
142
+ reason: state.turnState.reason || '',
143
+ };
144
+ }
145
+
146
+ function sendTurnState(ws, context = '') {
147
+ return sendWs(ws, getTurnStatePayload(), context);
148
+ }
149
+
150
+ function setTurnState(phase, { sessionId = state.currentSessionId, reason = '', force = false } = {}) {
151
+ const normalizedPhase = phase === 'running' ? 'running' : 'idle';
152
+ const normalizedSessionId = sessionId || null;
153
+ const changed = force ||
154
+ state.turnState.phase !== normalizedPhase ||
155
+ state.turnState.sessionId !== normalizedSessionId;
156
+
157
+ if (!changed) return false;
158
+
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
+ }
178
+
179
+ function emitInterrupt(source) {
180
+ const interruptEvent = {
181
+ type: 'interrupt',
182
+ source,
183
+ timestamp: Date.now(),
184
+ uuid: crypto.randomUUID(),
185
+ };
186
+ const record = { seq: ++state.eventSeq, event: interruptEvent };
187
+ state.eventBuffer.push(record);
188
+ if (state.eventBuffer.length > EVENT_BUFFER_MAX) {
189
+ state.eventBuffer = state.eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
190
+ }
191
+ broadcast({ type: 'log_event', seq: record.seq, event: interruptEvent });
192
+ setTurnState('idle', { reason: `${source}_interrupt` });
193
+ }
194
+
195
+ function formatTtyInputChunk(chunk) {
196
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
197
+ return `len=${buf.length} hex=${buf.toString('hex')} base64=${buf.toString('base64')} utf8=${JSON.stringify(buf.toString('utf8'))}`;
198
+ }
199
+
200
+ module.exports = {
201
+ log,
202
+ wsLabel,
203
+ isAuthenticatedClient,
204
+ sendWs,
205
+ broadcast,
206
+ latestEventSeq,
207
+ getTurnStatePayload,
208
+ sendTurnState,
209
+ setTurnState,
210
+ recomputeEffectiveApprovalMode,
211
+ setClientApprovalMode,
212
+ setTurnApprovalFloorMode,
213
+ clearTurnApprovalFloorMode,
214
+ emitInterrupt,
215
+ formatTtyInputChunk,
216
+ };
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const pty = require('node-pty');
6
+ const { state, CLAUDE_STATE_FILE, isTTY } = require('./state');
7
+ const { log, broadcast, setTurnState, latestEventSeq, emitInterrupt, formatTtyInputChunk } = require('./logger');
8
+ const { stopTailing } = require('./transcript');
9
+ const { setupHooks } = require('./hooks');
10
+
11
+ function attachTtyForwarders() {
12
+ if (!isTTY || state.ttyInputForwarderAttached) return;
13
+
14
+ state.ttyInputHandler = (chunk) => {
15
+ if (state.DEBUG_TTY_INPUT) {
16
+ try {
17
+ log(`TTY input ${formatTtyInputChunk(chunk)}`);
18
+ } catch (err) {
19
+ log(`TTY input log error: ${err.message}`);
20
+ }
21
+ }
22
+ if (state.claudeProc) state.claudeProc.write(chunk);
23
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
24
+ if (buf.includes(0x03) && state.turnState.phase === 'running') {
25
+ log('Terminal Ctrl+C detected — injecting interrupt event');
26
+ emitInterrupt('terminal');
27
+ }
28
+ };
29
+ state.ttyResizeHandler = () => {
30
+ if (state.claudeProc) state.claudeProc.resize(process.stdout.columns, process.stdout.rows);
31
+ };
32
+
33
+ process.stdin.setRawMode(true);
34
+ process.stdin.resume();
35
+ process.stdin.on('data', state.ttyInputHandler);
36
+ process.stdout.on('resize', state.ttyResizeHandler);
37
+ state.ttyInputForwarderAttached = true;
38
+ }
39
+
40
+ function trustProjectCwd(cwd) {
41
+ const resolved = path.resolve(String(cwd || ''));
42
+ if (!resolved) return;
43
+
44
+ let stateData = {};
45
+ try {
46
+ stateData = JSON.parse(fs.readFileSync(CLAUDE_STATE_FILE, 'utf8'));
47
+ } catch {}
48
+
49
+ stateData.projects = stateData.projects && typeof stateData.projects === 'object' ? stateData.projects : {};
50
+ const keyVariants = process.platform === 'win32'
51
+ ? [resolved, resolved.replace(/\\/g, '/')]
52
+ : [resolved];
53
+
54
+ for (const projectKey of keyVariants) {
55
+ const existing = stateData.projects[projectKey];
56
+ stateData.projects[projectKey] = {
57
+ ...(existing && typeof existing === 'object' ? existing : {}),
58
+ hasTrustDialogAccepted: true,
59
+ projectOnboardingSeenCount: Number.isInteger(existing?.projectOnboardingSeenCount)
60
+ ? existing.projectOnboardingSeenCount
61
+ : 0,
62
+ };
63
+ }
64
+
65
+ fs.writeFileSync(CLAUDE_STATE_FILE, JSON.stringify(stateData, null, 2));
66
+ log(`Trusted Claude project cwd: ${resolved}`);
67
+ }
68
+
69
+ function spawnClaude() {
70
+ const isWin = process.platform === 'win32';
71
+ const shell = isWin ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');
72
+ const claudeCmd = state.CLAUDE_EXTRA_ARGS.length > 0
73
+ ? `claude ${state.CLAUDE_EXTRA_ARGS.join(' ')}`
74
+ : 'claude';
75
+ const args = isWin
76
+ ? ['-NoLogo', '-NoProfile', '-Command', claudeCmd]
77
+ : ['-c', claudeCmd];
78
+
79
+ const cols = isTTY ? process.stdout.columns : 120;
80
+ const rows = isTTY ? process.stdout.rows : 40;
81
+
82
+ const proc = state.claudeProc = pty.spawn(shell, args, {
83
+ name: 'xterm-256color',
84
+ cols,
85
+ rows,
86
+ cwd: state.CWD,
87
+ env: { ...process.env, FORCE_COLOR: '1', BRIDGE_PORT: String(state.PORT) },
88
+ });
89
+
90
+ log(`Claude spawned (pid ${state.claudeProc.pid}) — ${cols}x${rows} cmd="${claudeCmd}"`);
91
+ setTurnState('idle', { sessionId: state.currentSessionId, reason: 'claude_spawned' });
92
+ broadcast({
93
+ type: 'status',
94
+ status: 'running',
95
+ pid: proc.pid,
96
+ cwd: state.CWD,
97
+ sessionId: state.currentSessionId,
98
+ lastSeq: latestEventSeq(),
99
+ });
100
+
101
+ proc.onData((data) => {
102
+ if (isTTY) process.stdout.write(data);
103
+ broadcast({ type: 'pty_output', data });
104
+ });
105
+
106
+ attachTtyForwarders();
107
+
108
+ proc.onExit(({ exitCode, signal }) => {
109
+ if (state.claudeProc !== proc) {
110
+ log(`Ignoring stale Claude exit (pid ${proc.pid}, code=${exitCode}, signal=${signal})`);
111
+ return;
112
+ }
113
+ log(`Claude exited (code=${exitCode}, signal=${signal})`);
114
+ setTurnState('idle', { sessionId: state.currentSessionId, reason: 'pty_exit' });
115
+ broadcast({ type: 'pty_exit', exitCode, signal });
116
+ state.claudeProc = null;
117
+
118
+ if (isTTY) {
119
+ process.stdin.setRawMode(false);
120
+ process.stdin.pause();
121
+ }
122
+ stopTailing();
123
+ log('Bridge shutting down.');
124
+ setTimeout(() => process.exit(exitCode || 0), 300);
125
+ });
126
+ }
127
+
128
+ function restartClaude(newCwd) {
129
+ log(`Restarting Claude with new CWD: ${newCwd}`);
130
+ state.CWD = newCwd;
131
+ try {
132
+ trustProjectCwd(state.CWD);
133
+ } catch (err) {
134
+ log(`Failed to trust Claude project cwd "${state.CWD}": ${err.message}`);
135
+ }
136
+
137
+ stopTailing();
138
+
139
+ state.currentSessionId = null;
140
+ state.transcriptPath = null;
141
+ state.transcriptOffset = 0;
142
+ state.eventBuffer = [];
143
+ state.eventSeq = 0;
144
+ state.tailCatchingUp = false;
145
+ setTurnState('idle', { sessionId: null, reason: 'restart_claude' });
146
+
147
+ const procToRestart = state.claudeProc;
148
+ state.claudeProc = null;
149
+ if (procToRestart) {
150
+ procToRestart.kill();
151
+ }
152
+
153
+ setupHooks();
154
+ broadcast({ type: 'cwd_changed', cwd: state.CWD, sessionId: null, lastSeq: 0 });
155
+ spawnClaude();
156
+ }
157
+
158
+ module.exports = {
159
+ spawnClaude,
160
+ restartClaude,
161
+ attachTtyForwarders,
162
+ };
package/lib/state.js ADDED
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const os = require('os');
5
+
6
+ // --- Paths ---
7
+ const CLAUDE_HOME = path.join(os.homedir(), '.claude');
8
+ const CLAUDE_STATE_FILE = path.join(os.homedir(), '.claude.json');
9
+ const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
10
+ const LOG_FILE = path.join(CLAUDE_HOME, 'bridge.log');
11
+ const TOKEN_FILE = path.join(os.homedir(), '.claude-remote-token');
12
+ const IMAGE_UPLOAD_DIR = os.tmpdir();
13
+
14
+ // --- Constants ---
15
+ const AUTH_HELLO_TIMEOUT_MS = 5000;
16
+ const WS_CLOSE_AUTH_FAILED = 4001;
17
+ const WS_CLOSE_AUTH_TIMEOUT = 4002;
18
+ const WS_CLOSE_REASON_AUTH_FAILED = 'auth_failed';
19
+ const WS_CLOSE_REASON_AUTH_TIMEOUT = 'auth_timeout';
20
+ const EVENT_BUFFER_MAX = 5000;
21
+ const LEGACY_REPLAY_DELAY_MS = 1500;
22
+ const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
23
+ const LINUX_CLIPBOARD_READY_GRACE_MS = 400;
24
+ const LINUX_AT_PROMPT_SUBMIT_DELAY_MS = 450;
25
+ const LINUX_AT_IMAGE_CLEANUP_DELAY_MS = 10 * 60 * 1000;
26
+
27
+ // --- Auto-allow sets ---
28
+ const ALWAYS_AUTO_ALLOW = new Set(['TaskCreate', 'TaskUpdate']);
29
+ const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
30
+
31
+ // --- TTY ---
32
+ const isTTY = process.stdin.isTTY && process.stdout.isTTY;
33
+
34
+ // --- Mutable shared state ---
35
+ const state = {
36
+ // Config (set once at startup)
37
+ PORT: 3100,
38
+ CWD: process.cwd(),
39
+ AUTH_TOKEN: null,
40
+ AUTH_DISABLED: false,
41
+ CLAUDE_EXTRA_ARGS: [],
42
+ DEBUG_TTY_INPUT: false,
43
+
44
+ // PTY
45
+ claudeProc: null,
46
+
47
+ // Transcript
48
+ transcriptPath: null,
49
+ currentSessionId: null,
50
+ transcriptOffset: 0,
51
+ eventBuffer: [],
52
+ eventSeq: 0,
53
+ tailTimer: null,
54
+ tailRemainder: Buffer.alloc(0),
55
+ tailCatchingUp: false,
56
+
57
+ // Session switch
58
+ switchWatcher: null,
59
+ switchWatcherDelayTimer: null,
60
+ expectingSwitch: false,
61
+ expectingSwitchTimer: null,
62
+ pendingSwitchTarget: null,
63
+ pendingInitialClearTranscript: null,
64
+
65
+ // Turn state
66
+ turnStateVersion: 0,
67
+ turnState: {
68
+ phase: 'idle',
69
+ sessionId: null,
70
+ version: 0,
71
+ updatedAt: Date.now(),
72
+ },
73
+
74
+ // WebSocket
75
+ wss: null,
76
+ nextWsId: 0,
77
+
78
+ // Permission approval
79
+ approvalSeq: 0,
80
+ pendingApprovals: new Map(),
81
+ pendingImageUploads: new Map(),
82
+ approvalMode: 'default',
83
+ turnApprovalFloorMode: '',
84
+
85
+ // TTY forwarders
86
+ ttyInputForwarderAttached: false,
87
+ ttyInputHandler: null,
88
+ ttyResizeHandler: null,
89
+
90
+ // Linux clipboard
91
+ activeLinuxClipboardProc: null,
92
+ linuxImagePasteInFlight: false,
93
+ };
94
+
95
+ module.exports = {
96
+ state,
97
+ CLAUDE_HOME,
98
+ CLAUDE_STATE_FILE,
99
+ PROJECTS_DIR,
100
+ LOG_FILE,
101
+ TOKEN_FILE,
102
+ IMAGE_UPLOAD_DIR,
103
+ AUTH_HELLO_TIMEOUT_MS,
104
+ WS_CLOSE_AUTH_FAILED,
105
+ WS_CLOSE_AUTH_TIMEOUT,
106
+ WS_CLOSE_REASON_AUTH_FAILED,
107
+ WS_CLOSE_REASON_AUTH_TIMEOUT,
108
+ EVENT_BUFFER_MAX,
109
+ LEGACY_REPLAY_DELAY_MS,
110
+ IMAGE_UPLOAD_TTL_MS,
111
+ LINUX_CLIPBOARD_READY_GRACE_MS,
112
+ LINUX_AT_PROMPT_SUBMIT_DELAY_MS,
113
+ LINUX_AT_IMAGE_CLEANUP_DELAY_MS,
114
+ ALWAYS_AUTO_ALLOW,
115
+ PARTIAL_AUTO_ALLOW,
116
+ isTTY,
117
+ };