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.
@@ -103,11 +103,13 @@ function persistSessionKey(deviceId, targetDeviceId, sessionKeys) {
103
103
  try {
104
104
  ensureSessionStoreExists();
105
105
  const plainData = JSON.stringify({
106
- sharedKey: Array.from(sessionKeys.sharedKey),
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
- sharedKey: new Uint8Array(data.sharedKey),
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) {
@@ -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
- sharedKey: Uint8Array;
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 URL: ${chalk_1.default.cyan(`ws://${localIp}:${config_1.config.relayPort}`)}`);
117
- console.log(` Relay Port: ${chalk_1.default.cyan(String(config_1.config.relayPort))}`);
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
- .action(async () => {
133
- const spinner = (0, logger_1.createSpinner)('Starting relay server...').start();
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
- // Set pairing code on server for in-process validation
143
- websocket_1.wsClient.setPairingCode(pairingCode);
144
- const localIp = getLocalIp();
145
- const relayUrl = `ws://${localIp}:${config_1.config.relayPort}`;
146
- spinner.succeed(`Relay server started on ${relayUrl}\n`);
147
- // Display pairing info
148
- console.log(chalk_1.default.bold('Scan this QR code with the ForkOff mobile app:\n'));
149
- const pairingUrl = `forkoff://pair/${pairingCode}?relay=${encodeURIComponent(relayUrl)}`;
150
- qrcode_terminal_1.default.generate(pairingUrl, { small: true }, (code) => {
151
- console.log(code);
152
- });
153
- console.log(chalk_1.default.bold('\nOr enter this code manually:\n'));
154
- console.log(chalk_1.default.bgBlue.white.bold(` ${pairingCode} `));
155
- console.log();
156
- console.log(chalk_1.default.dim(`Relay: ${relayUrl}`));
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 (server already running)
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 URL: ${chalk_1.default.cyan(`ws://${localIp}:${config_1.config.relayPort}`)}`);
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
- .action(async () => {
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
- // Start embedded relay server before entering connection flow
228
- const localIp = getLocalIp();
229
- const relayUrl = `ws://${localIp}:${config_1.config.relayPort}`;
230
- await websocket_1.wsClient.startServer(config_1.config.relayPort);
231
- console.log(chalk_1.default.cyan(`Relay server started on ${relayUrl}`));
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.
@@ -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
- /** Set pairing code on the embedded server for in-process validation */
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: {