forkoff 1.0.19 → 1.1.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/README.md +7 -5
- package/dist/cloud-client.d.ts +30 -0
- package/dist/cloud-client.js +165 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +24 -0
- package/dist/crypto/e2eeManager.d.ts +8 -0
- package/dist/crypto/e2eeManager.js +90 -14
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +2 -1
- package/dist/crypto/keyExchange.d.ts +18 -0
- package/dist/crypto/keyExchange.js +37 -1
- package/dist/crypto/keyStorage.d.ts +4 -3
- package/dist/crypto/keyStorage.js +6 -4
- package/dist/crypto/sessionPersistence.js +24 -2
- package/dist/crypto/types.d.ts +3 -1
- package/dist/index.js +150 -30
- package/dist/tools/claude-process.d.ts +5 -0
- package/dist/tools/claude-process.js +44 -0
- package/dist/websocket.d.ts +14 -1
- package/dist/websocket.js +159 -42
- package/eas.json +21 -0
- package/package.json +3 -1
|
@@ -103,11 +103,13 @@ function persistSessionKey(deviceId, targetDeviceId, sessionKeys) {
|
|
|
103
103
|
try {
|
|
104
104
|
ensureSessionStoreExists();
|
|
105
105
|
const plainData = JSON.stringify({
|
|
106
|
-
|
|
106
|
+
sendKey: Array.from(sessionKeys.sendKey),
|
|
107
|
+
receiveKey: Array.from(sessionKeys.receiveKey),
|
|
107
108
|
sessionId: sessionKeys.sessionId,
|
|
108
109
|
deviceId: sessionKeys.deviceId,
|
|
109
110
|
messageCounter: sessionKeys.messageCounter,
|
|
110
111
|
lastReceivedCounter: sessionKeys.lastReceivedCounter,
|
|
112
|
+
createdAt: sessionKeys.createdAt ?? Date.now(),
|
|
111
113
|
timestamp: new Date().toISOString(),
|
|
112
114
|
});
|
|
113
115
|
const filePath = getSessionFilePath(deviceId, targetDeviceId);
|
|
@@ -180,12 +182,32 @@ function loadPersistedSessionKey(deviceId, targetDeviceId) {
|
|
|
180
182
|
fs.unlinkSync(filePath);
|
|
181
183
|
return null;
|
|
182
184
|
}
|
|
185
|
+
// Support both new (sendKey/receiveKey) and legacy (sharedKey) formats
|
|
186
|
+
let sendKey;
|
|
187
|
+
let receiveKey;
|
|
188
|
+
if (data.sendKey && data.receiveKey) {
|
|
189
|
+
sendKey = new Uint8Array(data.sendKey);
|
|
190
|
+
receiveKey = new Uint8Array(data.receiveKey);
|
|
191
|
+
}
|
|
192
|
+
else if (data.sharedKey) {
|
|
193
|
+
// Legacy format: same key for both directions (pre-HKDF)
|
|
194
|
+
const legacyKey = new Uint8Array(data.sharedKey);
|
|
195
|
+
sendKey = legacyKey;
|
|
196
|
+
receiveKey = legacyKey;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
console.error('[Security] Persisted session has no valid key data');
|
|
200
|
+
fs.unlinkSync(filePath);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
183
203
|
return {
|
|
184
|
-
|
|
204
|
+
sendKey,
|
|
205
|
+
receiveKey,
|
|
185
206
|
sessionId: data.sessionId,
|
|
186
207
|
deviceId: data.deviceId || targetDeviceId,
|
|
187
208
|
messageCounter: data.messageCounter || 0,
|
|
188
209
|
lastReceivedCounter: data.lastReceivedCounter || -1,
|
|
210
|
+
createdAt: data.createdAt,
|
|
189
211
|
};
|
|
190
212
|
}
|
|
191
213
|
catch (error) {
|
package/dist/crypto/types.d.ts
CHANGED
|
@@ -25,11 +25,13 @@ export interface EncryptedMessage {
|
|
|
25
25
|
}
|
|
26
26
|
/** Derived session keys for a specific device-to-device connection */
|
|
27
27
|
export interface SessionKeys {
|
|
28
|
-
|
|
28
|
+
sendKey: Uint8Array;
|
|
29
|
+
receiveKey: Uint8Array;
|
|
29
30
|
sessionId: string;
|
|
30
31
|
deviceId: string;
|
|
31
32
|
messageCounter: number;
|
|
32
33
|
lastReceivedCounter: number;
|
|
34
|
+
createdAt?: number;
|
|
33
35
|
}
|
|
34
36
|
/** Ed25519 signing key pair for identity verification during key exchange */
|
|
35
37
|
export interface SigningKeyPair {
|
package/dist/index.js
CHANGED
|
@@ -112,9 +112,16 @@ function createProgram() {
|
|
|
112
112
|
}
|
|
113
113
|
if (options.show || (!options.port && !options.name && !options.reset)) {
|
|
114
114
|
const localIp = getLocalIp();
|
|
115
|
+
const isCloud = config_1.config.relayMode === 'cloud';
|
|
115
116
|
console.log(chalk_1.default.bold('\nCurrent Configuration:'));
|
|
116
|
-
console.log(` Relay
|
|
117
|
-
|
|
117
|
+
console.log(` Relay Mode: ${isCloud ? chalk_1.default.green('Cloud') : chalk_1.default.cyan('Local')}`);
|
|
118
|
+
if (isCloud) {
|
|
119
|
+
console.log(` Relay URL: ${chalk_1.default.cyan(config_1.config.wsUrl)}`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
console.log(` Relay URL: ${chalk_1.default.cyan(`ws://${localIp}:${config_1.config.relayPort}`)}`);
|
|
123
|
+
}
|
|
124
|
+
console.log(` Relay Port: ${chalk_1.default.cyan(String(config_1.config.relayPort))} ${isCloud ? chalk_1.default.dim('(local mode only)') : ''}`);
|
|
118
125
|
console.log(` Device Name: ${chalk_1.default.cyan(config_1.config.deviceName)}`);
|
|
119
126
|
console.log(` Device ID: ${chalk_1.default.cyan(config_1.config.deviceId || 'Not registered')}`);
|
|
120
127
|
console.log(` Paired: ${config_1.config.isPaired ? chalk_1.default.green('Yes') : chalk_1.default.yellow('No')}`);
|
|
@@ -129,37 +136,59 @@ function createProgram() {
|
|
|
129
136
|
program
|
|
130
137
|
.command('pair')
|
|
131
138
|
.description('Generate pairing code to connect with mobile app')
|
|
132
|
-
.
|
|
133
|
-
|
|
139
|
+
.option('--local', 'Use local network relay instead of cloud relay')
|
|
140
|
+
.action(async (options) => {
|
|
141
|
+
const isLocal = options.local;
|
|
142
|
+
const spinner = (0, logger_1.createSpinner)(isLocal ? 'Starting local relay server...' : 'Connecting to cloud relay...').start();
|
|
134
143
|
try {
|
|
135
144
|
// Ensure we have a deviceId
|
|
136
145
|
config_1.config.ensureDeviceId();
|
|
137
|
-
// Start embedded relay server
|
|
138
|
-
await websocket_1.wsClient.startServer(config_1.config.relayPort);
|
|
139
146
|
// Generate random 8-char pairing code
|
|
140
147
|
const pairingCode = crypto.randomBytes(4).toString('hex').toUpperCase().slice(0, 8);
|
|
141
148
|
config_1.config.pairingCode = pairingCode;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
149
|
+
if (isLocal) {
|
|
150
|
+
// Local mode: start embedded relay server (existing behavior)
|
|
151
|
+
config_1.config.relayMode = 'local';
|
|
152
|
+
await websocket_1.wsClient.startServer(config_1.config.relayPort);
|
|
153
|
+
// Set pairing code on server for in-process validation
|
|
154
|
+
websocket_1.wsClient.setPairingCode(pairingCode);
|
|
155
|
+
const localIp = getLocalIp();
|
|
156
|
+
const relayUrl = `ws://${localIp}:${config_1.config.relayPort}`;
|
|
157
|
+
spinner.succeed(`Local relay server started on ${relayUrl}\n`);
|
|
158
|
+
// QR includes relay URL for local mode
|
|
159
|
+
const pairingUrl = `forkoff://pair/${pairingCode}?relay=${encodeURIComponent(relayUrl)}`;
|
|
160
|
+
console.log(chalk_1.default.bold('Scan this QR code with the ForkOff mobile app:\n'));
|
|
161
|
+
qrcode_terminal_1.default.generate(pairingUrl, { small: true }, (code) => {
|
|
162
|
+
console.log(code);
|
|
163
|
+
});
|
|
164
|
+
console.log(chalk_1.default.bold('\nOr enter this code manually:\n'));
|
|
165
|
+
console.log(chalk_1.default.bgBlue.white.bold(` ${pairingCode} `));
|
|
166
|
+
console.log();
|
|
167
|
+
console.log(chalk_1.default.dim(`Relay: ${relayUrl}`));
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Cloud mode (default): connect to cloud relay as a client
|
|
171
|
+
config_1.config.relayMode = 'cloud';
|
|
172
|
+
await websocket_1.wsClient.connectToRelay(config_1.config.wsUrl);
|
|
173
|
+
// Register pairing code with the relay
|
|
174
|
+
websocket_1.wsClient.setPairingCode(pairingCode);
|
|
175
|
+
spinner.succeed(`Connected to cloud relay\n`);
|
|
176
|
+
// QR without relay URL — mobile uses its default cloud connection
|
|
177
|
+
const pairingUrl = `forkoff://pair/${pairingCode}`;
|
|
178
|
+
console.log(chalk_1.default.bold('Scan this QR code with the ForkOff mobile app:\n'));
|
|
179
|
+
qrcode_terminal_1.default.generate(pairingUrl, { small: true }, (code) => {
|
|
180
|
+
console.log(code);
|
|
181
|
+
});
|
|
182
|
+
console.log(chalk_1.default.bold('\nOr enter this code manually:\n'));
|
|
183
|
+
console.log(chalk_1.default.bgBlue.white.bold(` ${pairingCode} `));
|
|
184
|
+
console.log();
|
|
185
|
+
console.log(chalk_1.default.dim(`Cloud relay: ${config_1.config.wsUrl}`));
|
|
186
|
+
}
|
|
157
187
|
console.log();
|
|
158
188
|
// Wait for pairing
|
|
159
189
|
console.log(chalk_1.default.yellow('Waiting for mobile app to scan...'));
|
|
160
190
|
console.log(chalk_1.default.dim('Press Ctrl+C to cancel\n'));
|
|
161
191
|
const pairData = await waitForPairing();
|
|
162
|
-
// Server already sent pair_device_ack to mobile
|
|
163
192
|
config_1.config.pairedAt = new Date().toISOString();
|
|
164
193
|
console.log(chalk_1.default.green('\n\u2713 Device paired successfully!'));
|
|
165
194
|
console.log(chalk_1.default.dim('\nStarting connection...\n'));
|
|
@@ -173,7 +202,7 @@ function createProgram() {
|
|
|
173
202
|
// Non-critical — don't fail pairing over this
|
|
174
203
|
}
|
|
175
204
|
}
|
|
176
|
-
// Continue to main connection (
|
|
205
|
+
// Continue to main connection (transport already running)
|
|
177
206
|
await startConnection();
|
|
178
207
|
}
|
|
179
208
|
catch (error) {
|
|
@@ -191,11 +220,18 @@ function createProgram() {
|
|
|
191
220
|
return;
|
|
192
221
|
}
|
|
193
222
|
const localIp = getLocalIp();
|
|
223
|
+
const isCloud = config_1.config.relayMode === 'cloud';
|
|
194
224
|
console.log(chalk_1.default.bold('\nDevice Status:'));
|
|
195
225
|
console.log(` Device ID: ${chalk_1.default.cyan(config_1.config.deviceId)}`);
|
|
196
226
|
console.log(` Device Name: ${chalk_1.default.cyan(config_1.config.deviceName)}`);
|
|
197
227
|
console.log(` Paired: ${config_1.config.isPaired ? chalk_1.default.green('Yes') : chalk_1.default.yellow('No')}`);
|
|
198
|
-
console.log(` Relay
|
|
228
|
+
console.log(` Relay Mode: ${isCloud ? chalk_1.default.green('Cloud') : chalk_1.default.cyan('Local')}`);
|
|
229
|
+
if (isCloud) {
|
|
230
|
+
console.log(` Relay URL: ${chalk_1.default.cyan(config_1.config.wsUrl)}`);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
console.log(` Relay URL: ${chalk_1.default.cyan(`ws://${localIp}:${config_1.config.relayPort}`)}`);
|
|
234
|
+
}
|
|
199
235
|
console.log(` Mobile: ${websocket_1.wsClient.isConnected ? chalk_1.default.green('Connected') : chalk_1.default.yellow('Not connected')}`);
|
|
200
236
|
if (config_1.config.pairedAt) {
|
|
201
237
|
console.log(` Paired At: ${chalk_1.default.dim(config_1.config.pairedAt)}`);
|
|
@@ -205,7 +241,8 @@ function createProgram() {
|
|
|
205
241
|
program
|
|
206
242
|
.command('connect')
|
|
207
243
|
.description('Reconnect to ForkOff (for previously paired devices)')
|
|
208
|
-
.
|
|
244
|
+
.option('--local', 'Use local network relay instead of cloud relay')
|
|
245
|
+
.action(async (options) => {
|
|
209
246
|
if (!config_1.config.deviceId) {
|
|
210
247
|
console.log(chalk_1.default.yellow('Device not registered. Run "forkoff pair" first.'));
|
|
211
248
|
return;
|
|
@@ -224,11 +261,20 @@ function createProgram() {
|
|
|
224
261
|
// Non-critical
|
|
225
262
|
}
|
|
226
263
|
}
|
|
227
|
-
//
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
264
|
+
// Determine relay mode: explicit flag > saved config
|
|
265
|
+
const useLocal = options.local || config_1.config.relayMode === 'local';
|
|
266
|
+
if (useLocal) {
|
|
267
|
+
// Local mode: start embedded relay server
|
|
268
|
+
const localIp = getLocalIp();
|
|
269
|
+
const relayUrl = `ws://${localIp}:${config_1.config.relayPort}`;
|
|
270
|
+
await websocket_1.wsClient.startServer(config_1.config.relayPort);
|
|
271
|
+
console.log(chalk_1.default.cyan(`Local relay server started on ${relayUrl}`));
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// Cloud mode (default): connect to cloud relay
|
|
275
|
+
await websocket_1.wsClient.connectToRelay(config_1.config.wsUrl);
|
|
276
|
+
console.log(chalk_1.default.cyan(`Connected to cloud relay (${config_1.config.wsUrl})`));
|
|
277
|
+
}
|
|
232
278
|
console.log(chalk_1.default.dim('Waiting for mobile app to connect...\n'));
|
|
233
279
|
await startConnection();
|
|
234
280
|
});
|
|
@@ -247,6 +293,8 @@ function createProgram() {
|
|
|
247
293
|
}
|
|
248
294
|
config_1.config.pairedAt = null;
|
|
249
295
|
config_1.config.pairingCode = null;
|
|
296
|
+
config_1.config.relayToken = null;
|
|
297
|
+
config_1.config.pairId = null;
|
|
250
298
|
console.log(chalk_1.default.green('Device disconnected and unpaired.'));
|
|
251
299
|
console.log(chalk_1.default.dim('Run "forkoff pair" to pair again.'));
|
|
252
300
|
});
|
|
@@ -406,6 +454,7 @@ function createProgram() {
|
|
|
406
454
|
const spinner = (0, logger_1.createSpinner)('Initializing...').start();
|
|
407
455
|
try {
|
|
408
456
|
tools_1.PermissionIpcManager.cleanupStaleTempFiles();
|
|
457
|
+
tools_1.claudeProcessManager.cleanupAllPermissionState();
|
|
409
458
|
spinner.succeed('Ready!\n');
|
|
410
459
|
// Detect connected tools
|
|
411
460
|
spinner.start('Detecting AI coding tools...');
|
|
@@ -718,6 +767,8 @@ function createProgram() {
|
|
|
718
767
|
// Handle transcript fetch requests from mobile
|
|
719
768
|
websocket_1.wsClient.on('transcript_fetch', async (data) => {
|
|
720
769
|
console.log(chalk_1.default.dim(`[Transcript] Fetching: offset: ${data.offset}, limit: ${data.limit}`));
|
|
770
|
+
// Signal loading state to mobile
|
|
771
|
+
websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'loading' });
|
|
721
772
|
try {
|
|
722
773
|
// SECURITY: Validate transcript path is under ~/.claude/projects/ to prevent path traversal
|
|
723
774
|
const resolvedTranscriptPath = path.resolve(data.transcriptPath);
|
|
@@ -725,6 +776,7 @@ function createProgram() {
|
|
|
725
776
|
const relPath = path.relative(claudeProjectsDir, resolvedTranscriptPath);
|
|
726
777
|
if (relPath.startsWith('..') || path.isAbsolute(relPath)) {
|
|
727
778
|
console.warn(chalk_1.default.yellow(`[Transcript] Access denied — path outside ~/.claude/projects/`));
|
|
779
|
+
websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'error', error: 'Access denied' });
|
|
728
780
|
return;
|
|
729
781
|
}
|
|
730
782
|
const result = await transcript_streamer_1.transcriptStreamer.fetchHistory(resolvedTranscriptPath, data.offset || 0, data.limit || 100, data.reverse !== false // Default to true (most recent first)
|
|
@@ -737,9 +789,12 @@ function createProgram() {
|
|
|
737
789
|
offset: data.offset || 0,
|
|
738
790
|
requestedBy: data.requestedBy,
|
|
739
791
|
});
|
|
792
|
+
// Signal ready state to mobile
|
|
793
|
+
websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'ready' });
|
|
740
794
|
}
|
|
741
795
|
catch (error) {
|
|
742
796
|
console.error(chalk_1.default.red(`[Transcript] Error: ${error.message}`));
|
|
797
|
+
websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'error', error: error.message });
|
|
743
798
|
}
|
|
744
799
|
});
|
|
745
800
|
// Handle transcript subscribe
|
|
@@ -760,6 +815,16 @@ function createProgram() {
|
|
|
760
815
|
console.log(chalk_1.default.dim(`[Transcript] Unsubscribing from session`));
|
|
761
816
|
transcript_streamer_1.transcriptStreamer.unsubscribeFromUpdates(data.sessionKey);
|
|
762
817
|
});
|
|
818
|
+
// Re-send all sessions when E2EE establishes (bypasses queue TTL expiry)
|
|
819
|
+
websocket_1.wsClient.on('e2ee_established', () => {
|
|
820
|
+
if (tools_1.claudeSessionDetector.isClaudeInstalled()) {
|
|
821
|
+
const sessions = tools_1.claudeSessionDetector.scanSessions();
|
|
822
|
+
if (sessions.length > 0) {
|
|
823
|
+
console.log(chalk_1.default.cyan(`[Claude] E2EE established — re-sending ${sessions.length} session(s)`));
|
|
824
|
+
websocket_1.wsClient.sendClaudeSessions(sessions);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
});
|
|
763
828
|
// Handle claude sessions request - mobile app wants current sessions
|
|
764
829
|
websocket_1.wsClient.on('claude_sessions_request', () => {
|
|
765
830
|
console.log(chalk_1.default.cyan(`[Claude] Sessions requested by mobile`));
|
|
@@ -783,6 +848,54 @@ function createProgram() {
|
|
|
783
848
|
websocket_1.wsClient.sendToolStatusUpdate('claude_code', hasActiveSession ? 'active' : 'inactive');
|
|
784
849
|
}
|
|
785
850
|
});
|
|
851
|
+
// Handle SDK session history requests from mobile (local JSONL lookup)
|
|
852
|
+
// Mobile sends this when opening a session — CLI resolves it locally from disk
|
|
853
|
+
websocket_1.wsClient.on('sdk_session_history', async (data) => {
|
|
854
|
+
console.log(chalk_1.default.dim(`[Transcript] SDK session history requested`));
|
|
855
|
+
// Signal loading state to mobile
|
|
856
|
+
websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'loading' });
|
|
857
|
+
try {
|
|
858
|
+
// Find session locally by sessionKey or claudeSessionId
|
|
859
|
+
let sessions = tools_1.claudeSessionDetector.getSessions();
|
|
860
|
+
let session = sessions.find((s) => s.sessionKey === data.sessionKey);
|
|
861
|
+
if (!session && data.claudeSessionId) {
|
|
862
|
+
session = sessions.find((s) => s.sessionKey === data.claudeSessionId);
|
|
863
|
+
}
|
|
864
|
+
// Fallback: rescan if not cached
|
|
865
|
+
if (!session) {
|
|
866
|
+
const freshSessions = tools_1.claudeSessionDetector.scanSessions();
|
|
867
|
+
session = freshSessions.find((s) => s.sessionKey === data.sessionKey ||
|
|
868
|
+
(data.claudeSessionId && s.sessionKey === data.claudeSessionId));
|
|
869
|
+
}
|
|
870
|
+
if (session?.transcriptPath) {
|
|
871
|
+
const result = await transcript_streamer_1.transcriptStreamer.fetchHistory(session.transcriptPath, data.offset || 0, data.limit || 200, true);
|
|
872
|
+
console.log(chalk_1.default.dim(`[Transcript] Sending history: ${result.entries.length} entries`));
|
|
873
|
+
websocket_1.wsClient.sendTranscriptHistory({
|
|
874
|
+
sessionKey: data.sessionKey,
|
|
875
|
+
...result,
|
|
876
|
+
offset: data.offset || 0,
|
|
877
|
+
requestedBy: data.requestedBy,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
// No local transcript — send empty response so mobile stops loading
|
|
882
|
+
websocket_1.wsClient.sendTranscriptHistory({
|
|
883
|
+
sessionKey: data.sessionKey,
|
|
884
|
+
entries: [],
|
|
885
|
+
totalEntries: 0,
|
|
886
|
+
offset: 0,
|
|
887
|
+
hasMore: false,
|
|
888
|
+
requestedBy: data.requestedBy,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
// Signal ready state
|
|
892
|
+
websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'ready' });
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
console.error(chalk_1.default.red(`[Transcript] SDK session history error: ${error.message}`));
|
|
896
|
+
websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'error', error: error.message });
|
|
897
|
+
}
|
|
898
|
+
});
|
|
786
899
|
// Forward live transcript updates to WebSocket
|
|
787
900
|
transcript_streamer_1.transcriptStreamer.on('update', (data) => {
|
|
788
901
|
console.log(chalk_1.default.green(`[Transcript] Sending update for ${data.sessionKey}: ${data.entry?.type}`));
|
|
@@ -985,6 +1098,13 @@ function createProgram() {
|
|
|
985
1098
|
websocket_1.wsClient.on('disconnected', (reason) => {
|
|
986
1099
|
console.log(chalk_1.default.yellow(`\nMobile disconnected: ${reason}`));
|
|
987
1100
|
console.log(chalk_1.default.dim('Waiting for mobile to reconnect...'));
|
|
1101
|
+
tools_1.claudeProcessManager.autoAllowAllPendingPrompts();
|
|
1102
|
+
tools_1.claudeProcessManager.cleanupAllPermissionState();
|
|
1103
|
+
tools_1.claudeProcessManager.clearAllTakenOver();
|
|
1104
|
+
});
|
|
1105
|
+
websocket_1.wsClient.on('session_release', (data) => {
|
|
1106
|
+
console.log(chalk_1.default.dim(`[Session] Mobile released session: ${data.sessionKey}`));
|
|
1107
|
+
tools_1.claudeProcessManager.releaseSession(data.sessionKey);
|
|
988
1108
|
});
|
|
989
1109
|
websocket_1.wsClient.on('error', (error) => {
|
|
990
1110
|
console.error(chalk_1.default.red(`Connection error: ${error.message}`));
|
|
@@ -241,6 +241,11 @@ declare class ClaudeProcessManager extends EventEmitter {
|
|
|
241
241
|
* Clear all taken-over sessions (e.g., when mobile disconnects).
|
|
242
242
|
*/
|
|
243
243
|
clearAllTakenOver(): void;
|
|
244
|
+
/**
|
|
245
|
+
* Release a single session — clean up its hooks and IPC.
|
|
246
|
+
* Called when mobile navigates away from the session screen after taking over.
|
|
247
|
+
*/
|
|
248
|
+
releaseSession(sessionKey: string): void;
|
|
244
249
|
/**
|
|
245
250
|
* Get all pending permission prompts across all IPC managers.
|
|
246
251
|
* Used to sync pending permissions to mobile on take-over.
|
|
@@ -822,6 +822,50 @@ class ClaudeProcessManager extends events_1.EventEmitter {
|
|
|
822
822
|
this.takenOverSessions.clear();
|
|
823
823
|
console.log(`[Claude Process] All taken-over sessions cleared`);
|
|
824
824
|
}
|
|
825
|
+
/**
|
|
826
|
+
* Release a single session — clean up its hooks and IPC.
|
|
827
|
+
* Called when mobile navigates away from the session screen after taking over.
|
|
828
|
+
*/
|
|
829
|
+
releaseSession(sessionKey) {
|
|
830
|
+
// Find the process by sessionKey or terminalSessionId
|
|
831
|
+
let terminalSessionId;
|
|
832
|
+
let directory;
|
|
833
|
+
for (const [id, info] of this.processes) {
|
|
834
|
+
if (id === sessionKey || info.sessionKey === sessionKey) {
|
|
835
|
+
terminalSessionId = id;
|
|
836
|
+
directory = info.directory;
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// Also check closed sessions in case the process already exited
|
|
841
|
+
if (!terminalSessionId) {
|
|
842
|
+
for (const [id, info] of this.closedSessions) {
|
|
843
|
+
if (id === sessionKey || info.sessionKey === sessionKey) {
|
|
844
|
+
terminalSessionId = id;
|
|
845
|
+
directory = info.directory;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (terminalSessionId) {
|
|
851
|
+
// Stop permission IPC for this session
|
|
852
|
+
this.stopPermissionIpc(terminalSessionId);
|
|
853
|
+
// Remove hook from directory if no other active sessions use it
|
|
854
|
+
if (directory) {
|
|
855
|
+
const otherSessionsInDir = Array.from(this.processes.values())
|
|
856
|
+
.filter(p => p.directory === directory && p.terminalSessionId !== terminalSessionId);
|
|
857
|
+
if (otherSessionsInDir.length === 0) {
|
|
858
|
+
this.removeHook(directory);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
// Clear taken-over state
|
|
862
|
+
this.takenOverSessions.delete(terminalSessionId);
|
|
863
|
+
console.log(`[Claude Process] Session released: ${terminalSessionId}`);
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
console.log(`[Claude Process] Session release: no matching session found for ${sessionKey}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
825
869
|
/**
|
|
826
870
|
* Get all pending permission prompts across all IPC managers.
|
|
827
871
|
* Used to sync pending permissions to mobile on take-over.
|
package/dist/websocket.d.ts
CHANGED
|
@@ -38,9 +38,13 @@ export declare class WebSocketClient extends EventEmitter {
|
|
|
38
38
|
private server;
|
|
39
39
|
private heartbeatInterval;
|
|
40
40
|
private _sessionId;
|
|
41
|
+
private isCloudRelay;
|
|
41
42
|
private e2eeManager;
|
|
42
43
|
private e2eeInitialized;
|
|
43
44
|
private e2eePeerDeviceId;
|
|
45
|
+
private _keyExchangePending;
|
|
46
|
+
private _keyExchangeDebounceTimer;
|
|
47
|
+
private _keyExchangeDebounceTarget;
|
|
44
48
|
private pendingSensitiveMessages;
|
|
45
49
|
private static readonly SENSITIVE_QUEUE_TTL_MS;
|
|
46
50
|
private static readonly MAX_PENDING_SENSITIVE;
|
|
@@ -48,7 +52,11 @@ export declare class WebSocketClient extends EventEmitter {
|
|
|
48
52
|
get sessionId(): string;
|
|
49
53
|
/** Start the embedded relay server and wire up event forwarding */
|
|
50
54
|
startServer(port: number): Promise<void>;
|
|
51
|
-
/**
|
|
55
|
+
/** Connect to a cloud relay as a Socket.io client (instead of running a local server) */
|
|
56
|
+
connectToRelay(url: string): Promise<void>;
|
|
57
|
+
/** Wire up event forwarding from the transport (shared between startServer and connectToRelay) */
|
|
58
|
+
private wireUpTransportEvents;
|
|
59
|
+
/** Set pairing code on the transport for validation */
|
|
52
60
|
setPairingCode(code: string): void;
|
|
53
61
|
/**
|
|
54
62
|
* Initialize E2EE manager: generate/load keys.
|
|
@@ -195,6 +203,11 @@ export declare class WebSocketClient extends EventEmitter {
|
|
|
195
203
|
setUsageTracker(tracker: UsageTracker): void;
|
|
196
204
|
/** Send all usage stats to mobile (called after E2EE established and on request) */
|
|
197
205
|
sendAllUsageStats(): void;
|
|
206
|
+
sendSessionLoading(data: {
|
|
207
|
+
sessionKey: string;
|
|
208
|
+
state: 'loading' | 'ready' | 'error';
|
|
209
|
+
error?: string;
|
|
210
|
+
}): void;
|
|
198
211
|
sendClaudeSessionEvent(data: {
|
|
199
212
|
sessionKey: string;
|
|
200
213
|
event: {
|