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/LICENSE +11 -7
- package/README.md +79 -118
- 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 +142 -30
- package/dist/websocket.d.ts +14 -1
- package/dist/websocket.js +159 -42
- package/package.json +4 -2
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
|
});
|
|
@@ -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}`));
|
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: {
|