forkoff 1.0.18 → 1.1.0

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/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
  });
@@ -718,6 +766,8 @@ function createProgram() {
718
766
  // Handle transcript fetch requests from mobile
719
767
  websocket_1.wsClient.on('transcript_fetch', async (data) => {
720
768
  console.log(chalk_1.default.dim(`[Transcript] Fetching: offset: ${data.offset}, limit: ${data.limit}`));
769
+ // Signal loading state to mobile
770
+ websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'loading' });
721
771
  try {
722
772
  // SECURITY: Validate transcript path is under ~/.claude/projects/ to prevent path traversal
723
773
  const resolvedTranscriptPath = path.resolve(data.transcriptPath);
@@ -725,6 +775,7 @@ function createProgram() {
725
775
  const relPath = path.relative(claudeProjectsDir, resolvedTranscriptPath);
726
776
  if (relPath.startsWith('..') || path.isAbsolute(relPath)) {
727
777
  console.warn(chalk_1.default.yellow(`[Transcript] Access denied — path outside ~/.claude/projects/`));
778
+ websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'error', error: 'Access denied' });
728
779
  return;
729
780
  }
730
781
  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 +788,12 @@ function createProgram() {
737
788
  offset: data.offset || 0,
738
789
  requestedBy: data.requestedBy,
739
790
  });
791
+ // Signal ready state to mobile
792
+ websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'ready' });
740
793
  }
741
794
  catch (error) {
742
795
  console.error(chalk_1.default.red(`[Transcript] Error: ${error.message}`));
796
+ websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'error', error: error.message });
743
797
  }
744
798
  });
745
799
  // Handle transcript subscribe
@@ -760,6 +814,16 @@ function createProgram() {
760
814
  console.log(chalk_1.default.dim(`[Transcript] Unsubscribing from session`));
761
815
  transcript_streamer_1.transcriptStreamer.unsubscribeFromUpdates(data.sessionKey);
762
816
  });
817
+ // Re-send all sessions when E2EE establishes (bypasses queue TTL expiry)
818
+ websocket_1.wsClient.on('e2ee_established', () => {
819
+ if (tools_1.claudeSessionDetector.isClaudeInstalled()) {
820
+ const sessions = tools_1.claudeSessionDetector.scanSessions();
821
+ if (sessions.length > 0) {
822
+ console.log(chalk_1.default.cyan(`[Claude] E2EE established — re-sending ${sessions.length} session(s)`));
823
+ websocket_1.wsClient.sendClaudeSessions(sessions);
824
+ }
825
+ }
826
+ });
763
827
  // Handle claude sessions request - mobile app wants current sessions
764
828
  websocket_1.wsClient.on('claude_sessions_request', () => {
765
829
  console.log(chalk_1.default.cyan(`[Claude] Sessions requested by mobile`));
@@ -783,6 +847,54 @@ function createProgram() {
783
847
  websocket_1.wsClient.sendToolStatusUpdate('claude_code', hasActiveSession ? 'active' : 'inactive');
784
848
  }
785
849
  });
850
+ // Handle SDK session history requests from mobile (local JSONL lookup)
851
+ // Mobile sends this when opening a session — CLI resolves it locally from disk
852
+ websocket_1.wsClient.on('sdk_session_history', async (data) => {
853
+ console.log(chalk_1.default.dim(`[Transcript] SDK session history requested`));
854
+ // Signal loading state to mobile
855
+ websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'loading' });
856
+ try {
857
+ // Find session locally by sessionKey or claudeSessionId
858
+ let sessions = tools_1.claudeSessionDetector.getSessions();
859
+ let session = sessions.find((s) => s.sessionKey === data.sessionKey);
860
+ if (!session && data.claudeSessionId) {
861
+ session = sessions.find((s) => s.sessionKey === data.claudeSessionId);
862
+ }
863
+ // Fallback: rescan if not cached
864
+ if (!session) {
865
+ const freshSessions = tools_1.claudeSessionDetector.scanSessions();
866
+ session = freshSessions.find((s) => s.sessionKey === data.sessionKey ||
867
+ (data.claudeSessionId && s.sessionKey === data.claudeSessionId));
868
+ }
869
+ if (session?.transcriptPath) {
870
+ const result = await transcript_streamer_1.transcriptStreamer.fetchHistory(session.transcriptPath, data.offset || 0, data.limit || 200, true);
871
+ console.log(chalk_1.default.dim(`[Transcript] Sending history: ${result.entries.length} entries`));
872
+ websocket_1.wsClient.sendTranscriptHistory({
873
+ sessionKey: data.sessionKey,
874
+ ...result,
875
+ offset: data.offset || 0,
876
+ requestedBy: data.requestedBy,
877
+ });
878
+ }
879
+ else {
880
+ // No local transcript — send empty response so mobile stops loading
881
+ websocket_1.wsClient.sendTranscriptHistory({
882
+ sessionKey: data.sessionKey,
883
+ entries: [],
884
+ totalEntries: 0,
885
+ offset: 0,
886
+ hasMore: false,
887
+ requestedBy: data.requestedBy,
888
+ });
889
+ }
890
+ // Signal ready state
891
+ websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'ready' });
892
+ }
893
+ catch (error) {
894
+ console.error(chalk_1.default.red(`[Transcript] SDK session history error: ${error.message}`));
895
+ websocket_1.wsClient.sendSessionLoading({ sessionKey: data.sessionKey, state: 'error', error: error.message });
896
+ }
897
+ });
786
898
  // Forward live transcript updates to WebSocket
787
899
  transcript_streamer_1.transcriptStreamer.on('update', (data) => {
788
900
  console.log(chalk_1.default.green(`[Transcript] Sending update for ${data.sessionKey}: ${data.entry?.type}`));
@@ -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: {