forkoff 1.0.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.
Files changed (56) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +173 -0
  3. package/dist/api.d.ts +44 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +76 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/approval.d.ts +46 -0
  8. package/dist/approval.d.ts.map +1 -0
  9. package/dist/approval.js +119 -0
  10. package/dist/approval.js.map +1 -0
  11. package/dist/config.d.ts +36 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +209 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +868 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/integration.d.ts +30 -0
  20. package/dist/integration.d.ts.map +1 -0
  21. package/dist/integration.js +84 -0
  22. package/dist/integration.js.map +1 -0
  23. package/dist/terminal.d.ts +25 -0
  24. package/dist/terminal.d.ts.map +1 -0
  25. package/dist/terminal.js +171 -0
  26. package/dist/terminal.js.map +1 -0
  27. package/dist/tools/claude-hooks.d.ts +97 -0
  28. package/dist/tools/claude-hooks.d.ts.map +1 -0
  29. package/dist/tools/claude-hooks.js +348 -0
  30. package/dist/tools/claude-hooks.js.map +1 -0
  31. package/dist/tools/claude-process.d.ts +271 -0
  32. package/dist/tools/claude-process.d.ts.map +1 -0
  33. package/dist/tools/claude-process.js +931 -0
  34. package/dist/tools/claude-process.js.map +1 -0
  35. package/dist/tools/claude-sessions.d.ts +60 -0
  36. package/dist/tools/claude-sessions.d.ts.map +1 -0
  37. package/dist/tools/claude-sessions.js +285 -0
  38. package/dist/tools/claude-sessions.js.map +1 -0
  39. package/dist/tools/detector.d.ts +64 -0
  40. package/dist/tools/detector.d.ts.map +1 -0
  41. package/dist/tools/detector.js +383 -0
  42. package/dist/tools/detector.js.map +1 -0
  43. package/dist/tools/index.d.ts +8 -0
  44. package/dist/tools/index.d.ts.map +1 -0
  45. package/dist/tools/index.js +15 -0
  46. package/dist/tools/index.js.map +1 -0
  47. package/dist/transcript-streamer.d.ts +68 -0
  48. package/dist/transcript-streamer.d.ts.map +1 -0
  49. package/dist/transcript-streamer.js +459 -0
  50. package/dist/transcript-streamer.js.map +1 -0
  51. package/dist/websocket.d.ts +133 -0
  52. package/dist/websocket.d.ts.map +1 -0
  53. package/dist/websocket.js +247 -0
  54. package/dist/websocket.js.map +1 -0
  55. package/nul +0 -0
  56. package/package.json +54 -0
package/dist/index.js ADDED
@@ -0,0 +1,868 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ var __importDefault = (this && this.__importDefault) || function (mod) {
37
+ return (mod && mod.__esModule) ? mod : { "default": mod };
38
+ };
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ const commander_1 = require("commander");
41
+ const chalk_1 = __importDefault(require("chalk"));
42
+ const ora_1 = __importDefault(require("ora"));
43
+ const qrcode_terminal_1 = __importDefault(require("qrcode-terminal"));
44
+ const config_1 = require("./config");
45
+ const api_1 = require("./api");
46
+ const websocket_1 = require("./websocket");
47
+ const terminal_1 = require("./terminal");
48
+ const approval_1 = require("./approval");
49
+ const tools_1 = require("./tools");
50
+ const transcript_streamer_1 = require("./transcript-streamer");
51
+ const fs = __importStar(require("fs"));
52
+ const path = __importStar(require("path"));
53
+ const os = __importStar(require("os"));
54
+ const program = new commander_1.Command();
55
+ program
56
+ .name('forkoff')
57
+ .description('CLI tool for ForkOff - Connect your AI coding tools to mobile')
58
+ .version('1.0.0');
59
+ // Configure API/WS URLs
60
+ program
61
+ .command('config')
62
+ .description('Configure ForkOff CLI settings')
63
+ .option('-a, --api <url>', 'Set API URL')
64
+ .option('-w, --ws <url>', 'Set WebSocket URL')
65
+ .option('-n, --name <name>', 'Set device name')
66
+ .option('--show', 'Show current configuration')
67
+ .option('--reset', 'Reset all configuration')
68
+ .action(async (options) => {
69
+ if (options.reset) {
70
+ config_1.config.reset();
71
+ console.log(chalk_1.default.green('Configuration reset successfully'));
72
+ return;
73
+ }
74
+ if (options.api) {
75
+ config_1.config.apiUrl = options.api;
76
+ console.log(chalk_1.default.green(`API URL set to: ${options.api}`));
77
+ }
78
+ if (options.ws) {
79
+ config_1.config.wsUrl = options.ws;
80
+ console.log(chalk_1.default.green(`WebSocket URL set to: ${options.ws}`));
81
+ }
82
+ if (options.name) {
83
+ config_1.config.deviceName = options.name;
84
+ console.log(chalk_1.default.green(`Device name set to: ${options.name}`));
85
+ }
86
+ if (options.show || (!options.api && !options.ws && !options.name && !options.reset)) {
87
+ console.log(chalk_1.default.bold('\nCurrent Configuration:'));
88
+ console.log(` API URL: ${chalk_1.default.cyan(config_1.config.apiUrl)}`);
89
+ console.log(` WebSocket: ${chalk_1.default.cyan(config_1.config.wsUrl)}`);
90
+ console.log(` Device Name: ${chalk_1.default.cyan(config_1.config.deviceName)}`);
91
+ console.log(` Device ID: ${chalk_1.default.cyan(config_1.config.deviceId || 'Not registered')}`);
92
+ console.log(` Paired: ${config_1.config.isPaired ? chalk_1.default.green('Yes') : chalk_1.default.yellow('No')}`);
93
+ console.log(` Config Path: ${chalk_1.default.dim(config_1.config.getPath())}`);
94
+ }
95
+ });
96
+ // Pair device with mobile app
97
+ program
98
+ .command('pair')
99
+ .description('Generate pairing code to connect with mobile app')
100
+ .action(async () => {
101
+ const spinner = (0, ora_1.default)('Connecting to ForkOff server...').start();
102
+ try {
103
+ // Check server health
104
+ const isHealthy = await api_1.api.healthCheck();
105
+ if (!isHealthy) {
106
+ spinner.fail('Cannot connect to ForkOff server');
107
+ console.log(chalk_1.default.yellow(`\nMake sure the server is running at ${config_1.config.apiUrl}`));
108
+ console.log(chalk_1.default.dim('Use "forkoff config --api <url>" to change the server URL'));
109
+ return;
110
+ }
111
+ spinner.text = 'Registering device...';
112
+ // Register device or refresh pairing code
113
+ let result;
114
+ if (config_1.config.deviceId) {
115
+ try {
116
+ result = await api_1.api.refreshPairingCode(config_1.config.deviceId);
117
+ }
118
+ catch {
119
+ // Device might not exist anymore, register fresh
120
+ result = await api_1.api.registerDevice();
121
+ }
122
+ }
123
+ else {
124
+ result = await api_1.api.registerDevice();
125
+ }
126
+ // Save device info
127
+ config_1.config.deviceId = result.device.id;
128
+ config_1.config.pairingCode = result.pairingCode;
129
+ spinner.succeed('Device registered successfully!\n');
130
+ // Display pairing info
131
+ console.log(chalk_1.default.bold('Scan this QR code with the ForkOff mobile app:\n'));
132
+ // Generate QR code with pairing URL
133
+ const pairingUrl = `forkoff://pair/${result.pairingCode}`;
134
+ qrcode_terminal_1.default.generate(pairingUrl, { small: true }, (code) => {
135
+ console.log(code);
136
+ });
137
+ console.log(chalk_1.default.bold('\nOr enter this code manually:\n'));
138
+ console.log(chalk_1.default.bgBlue.white.bold(` ${result.pairingCode} `));
139
+ console.log();
140
+ const expiresAt = new Date(result.expiresAt);
141
+ console.log(chalk_1.default.dim(`Code expires at: ${expiresAt.toLocaleTimeString()}`));
142
+ console.log();
143
+ // Wait for pairing
144
+ console.log(chalk_1.default.yellow('Waiting for mobile app to scan...'));
145
+ console.log(chalk_1.default.dim('Press Ctrl+C to cancel\n'));
146
+ await waitForPairing(result.device.id);
147
+ // Auto-connect after successful pairing
148
+ await startConnection();
149
+ }
150
+ catch (error) {
151
+ spinner.fail('Failed to register device');
152
+ console.error(chalk_1.default.red(error.message || 'Unknown error'));
153
+ }
154
+ });
155
+ // Check device status
156
+ program
157
+ .command('status')
158
+ .description('Check device connection status')
159
+ .action(async () => {
160
+ if (!config_1.config.deviceId) {
161
+ console.log(chalk_1.default.yellow('Device not registered. Run "forkoff pair" first.'));
162
+ return;
163
+ }
164
+ const spinner = (0, ora_1.default)('Checking status...').start();
165
+ try {
166
+ const status = await api_1.api.checkPairingStatus(config_1.config.deviceId);
167
+ spinner.stop();
168
+ console.log(chalk_1.default.bold('\nDevice Status:'));
169
+ console.log(` Device ID: ${chalk_1.default.cyan(config_1.config.deviceId)}`);
170
+ console.log(` Device Name: ${chalk_1.default.cyan(config_1.config.deviceName)}`);
171
+ console.log(` Paired: ${status.isPaired ? chalk_1.default.green('Yes') : chalk_1.default.yellow('No')}`);
172
+ if (status.isPaired) {
173
+ config_1.config.userId = status.userId;
174
+ config_1.config.pairedAt = config_1.config.pairedAt || new Date().toISOString();
175
+ console.log(` User ID: ${chalk_1.default.cyan(status.userId)}`);
176
+ }
177
+ if (websocket_1.wsClient.isConnected) {
178
+ console.log(` WebSocket: ${chalk_1.default.green('Connected')}`);
179
+ }
180
+ else {
181
+ console.log(` WebSocket: ${chalk_1.default.yellow('Disconnected')}`);
182
+ }
183
+ }
184
+ catch (error) {
185
+ spinner.fail('Failed to check status');
186
+ console.error(chalk_1.default.red(error.message || 'Unknown error'));
187
+ }
188
+ });
189
+ // Connect and stay online (for returning users who already paired)
190
+ program
191
+ .command('connect')
192
+ .description('Reconnect to ForkOff (for previously paired devices)')
193
+ .action(async () => {
194
+ if (!config_1.config.deviceId) {
195
+ console.log(chalk_1.default.yellow('Device not registered. Run "forkoff pair" first.'));
196
+ return;
197
+ }
198
+ if (!config_1.config.isPaired) {
199
+ console.log(chalk_1.default.yellow('Device not paired. Run "forkoff pair" and scan the QR code.'));
200
+ return;
201
+ }
202
+ await startConnection();
203
+ });
204
+ // Disconnect/unpair device
205
+ program
206
+ .command('disconnect')
207
+ .description('Disconnect and unpair device')
208
+ .action(async () => {
209
+ websocket_1.wsClient.disconnect();
210
+ config_1.config.userId = null;
211
+ config_1.config.pairedAt = null;
212
+ config_1.config.pairingCode = null;
213
+ console.log(chalk_1.default.green('Device disconnected and unpaired.'));
214
+ console.log(chalk_1.default.dim('Run "forkoff pair" to pair again.'));
215
+ });
216
+ // Detect and manage AI coding tools
217
+ program
218
+ .command('tools')
219
+ .description('Detect and manage AI coding tools')
220
+ .option('-d, --detect', 'Detect installed AI tools')
221
+ .option('-i, --install-hooks', 'Install ForkOff hooks for Claude Code')
222
+ .option('-u, --uninstall-hooks', 'Remove ForkOff hooks from Claude Code')
223
+ .option('-w, --watch', 'Watch tool status changes')
224
+ .action(async (options) => {
225
+ if (options.installHooks) {
226
+ const spinner = (0, ora_1.default)('Installing Claude Code hooks...').start();
227
+ try {
228
+ if (!tools_1.claudeHooksManager.canConfigure()) {
229
+ spinner.fail('Claude Code not found');
230
+ console.log(chalk_1.default.yellow('\nClaude Code must be installed to use hooks.'));
231
+ console.log(chalk_1.default.dim('Install Claude Code from: https://claude.ai/download'));
232
+ return;
233
+ }
234
+ await tools_1.claudeHooksManager.installHooks();
235
+ spinner.succeed('Claude Code hooks installed!');
236
+ console.log(chalk_1.default.green('\nForkOff will now receive events from Claude Code.'));
237
+ console.log(chalk_1.default.dim('Run "forkoff connect" to start receiving events.'));
238
+ }
239
+ catch (error) {
240
+ spinner.fail('Failed to install hooks');
241
+ console.error(chalk_1.default.red(error.message));
242
+ }
243
+ return;
244
+ }
245
+ if (options.uninstallHooks) {
246
+ const spinner = (0, ora_1.default)('Removing Claude Code hooks...').start();
247
+ try {
248
+ await tools_1.claudeHooksManager.uninstallHooks();
249
+ spinner.succeed('Claude Code hooks removed!');
250
+ }
251
+ catch (error) {
252
+ spinner.fail('Failed to remove hooks');
253
+ console.error(chalk_1.default.red(error.message));
254
+ }
255
+ return;
256
+ }
257
+ if (options.watch) {
258
+ console.log(chalk_1.default.bold('\nWatching for tool status changes...'));
259
+ console.log(chalk_1.default.dim('Press Ctrl+C to stop\n'));
260
+ tools_1.toolDetector.watchToolStatus((tools) => {
261
+ console.log(chalk_1.default.cyan(`[${new Date().toLocaleTimeString()}] Tool status update:`));
262
+ tools.forEach(tool => {
263
+ const statusColor = tool.status === 'running' ? chalk_1.default.green :
264
+ tool.status === 'configured' ? chalk_1.default.yellow : chalk_1.default.dim;
265
+ console.log(` ${tool.name}: ${statusColor(tool.status)}`);
266
+ });
267
+ console.log();
268
+ }, 3000);
269
+ // Keep alive
270
+ await new Promise(() => { });
271
+ return;
272
+ }
273
+ // Default: detect tools
274
+ const spinner = (0, ora_1.default)('Detecting AI coding tools...').start();
275
+ try {
276
+ const result = await tools_1.toolDetector.detectAll();
277
+ spinner.stop();
278
+ console.log(chalk_1.default.bold('\nDetected AI Coding Tools:\n'));
279
+ if (result.tools.length === 0) {
280
+ console.log(chalk_1.default.yellow(' No AI coding tools detected.'));
281
+ console.log(chalk_1.default.dim('\n Supported tools:'));
282
+ console.log(chalk_1.default.dim(' - Claude Code (https://claude.ai/download)'));
283
+ console.log(chalk_1.default.dim(' - Cursor (https://cursor.sh)'));
284
+ console.log(chalk_1.default.dim(' - GitHub Copilot (VS Code extension)'));
285
+ console.log(chalk_1.default.dim(' - Continue.dev (VS Code extension)'));
286
+ }
287
+ else {
288
+ result.tools.forEach(tool => {
289
+ const statusIcon = tool.status === 'running' ? chalk_1.default.green('●') :
290
+ tool.status === 'configured' ? chalk_1.default.yellow('○') :
291
+ chalk_1.default.dim('○');
292
+ console.log(` ${statusIcon} ${chalk_1.default.bold(tool.name)}`);
293
+ console.log(` Type: ${chalk_1.default.cyan(tool.type)}`);
294
+ if (tool.version) {
295
+ console.log(` Version: ${chalk_1.default.dim(tool.version)}`);
296
+ }
297
+ if (tool.path) {
298
+ console.log(` Path: ${chalk_1.default.dim(tool.path)}`);
299
+ }
300
+ console.log(` Status: ${tool.status === 'running' ? chalk_1.default.green('Running') :
301
+ tool.status === 'configured' ? chalk_1.default.yellow('Configured') :
302
+ chalk_1.default.dim('Detected')}`);
303
+ // Check if hooks are configured for Claude Code
304
+ if (tool.type === 'claude-code') {
305
+ const hooksConfigured = tools_1.claudeHooksManager.isHookConfigured();
306
+ console.log(` Hooks: ${hooksConfigured ? chalk_1.default.green('Installed') : chalk_1.default.yellow('Not installed')}`);
307
+ if (!hooksConfigured) {
308
+ console.log(chalk_1.default.dim(' Run "forkoff tools --install-hooks" to enable'));
309
+ }
310
+ }
311
+ console.log();
312
+ });
313
+ }
314
+ console.log(chalk_1.default.dim(`Platform: ${result.platform}`));
315
+ }
316
+ catch (error) {
317
+ spinner.fail('Tool detection failed');
318
+ console.error(chalk_1.default.red(error.message));
319
+ }
320
+ });
321
+ // Helper function to start connection and set up event handlers
322
+ async function startConnection() {
323
+ const spinner = (0, ora_1.default)('Connecting to ForkOff...').start();
324
+ try {
325
+ await websocket_1.wsClient.connect();
326
+ spinner.succeed('Connected to ForkOff!\n');
327
+ // Detect and report connected tools
328
+ spinner.start('Detecting AI coding tools...');
329
+ try {
330
+ const toolResult = await tools_1.toolDetector.detectAll();
331
+ if (toolResult.tools.length > 0) {
332
+ const toolsToReport = toolResult.tools.map(tool => ({
333
+ type: tool.type,
334
+ name: tool.name,
335
+ version: tool.version || null,
336
+ }));
337
+ await api_1.api.reportConnectedTools(config_1.config.deviceId, toolsToReport);
338
+ spinner.succeed(`Detected ${toolResult.tools.length} AI tool(s): ${toolResult.tools.map(t => t.name).join(', ')}`);
339
+ }
340
+ else {
341
+ spinner.info('No AI coding tools detected');
342
+ }
343
+ }
344
+ catch (toolError) {
345
+ spinner.warn('Tool detection skipped: ' + (toolError.message || 'unknown error'));
346
+ }
347
+ console.log();
348
+ console.log(chalk_1.default.green('Device is now online and ready to receive commands.'));
349
+ console.log(chalk_1.default.dim('Press Ctrl+C to disconnect\n'));
350
+ // Set up terminal output forwarding
351
+ terminal_1.terminalManager.on('output', (data) => {
352
+ websocket_1.wsClient.sendTerminalOutput(data);
353
+ });
354
+ terminal_1.terminalManager.on('cwd_changed', (data) => {
355
+ websocket_1.wsClient.sendTerminalCwd(data);
356
+ });
357
+ // When a session is auto-created (command received before terminal_create), send the cwd
358
+ terminal_1.terminalManager.on('session_created', (data) => {
359
+ console.log(chalk_1.default.dim(`[Terminal] Session auto-created: ${data.terminalSessionId} at ${data.cwd}`));
360
+ websocket_1.wsClient.sendTerminalCwd({
361
+ terminalSessionId: data.terminalSessionId,
362
+ cwd: data.cwd,
363
+ });
364
+ });
365
+ // Set up terminal create handler
366
+ websocket_1.wsClient.on('terminal_create', (data) => {
367
+ console.log(chalk_1.default.blue(`[Terminal] Creating session: ${data.terminalSessionId}`));
368
+ // Resolve the cwd (~ to home directory)
369
+ let resolvedCwd = data.cwd || process.cwd();
370
+ if (resolvedCwd === '~' || resolvedCwd.startsWith('~/')) {
371
+ const homedir = require('os').homedir();
372
+ resolvedCwd = resolvedCwd === '~' ? homedir : resolvedCwd.replace('~', homedir);
373
+ }
374
+ // Create the session
375
+ const session = terminal_1.terminalManager.createSession(data.terminalSessionId, resolvedCwd);
376
+ // Send back the resolved cwd
377
+ websocket_1.wsClient.sendTerminalCwd({
378
+ terminalSessionId: data.terminalSessionId,
379
+ cwd: session.cwd,
380
+ });
381
+ console.log(chalk_1.default.dim(`[Terminal] Session created with cwd: ${session.cwd}`));
382
+ });
383
+ // Set up event handlers
384
+ websocket_1.wsClient.on('terminal_command', async (data) => {
385
+ // Check if this is a Claude terminal session
386
+ if (tools_1.claudeProcessManager.isClaudeSession(data.terminalSessionId)) {
387
+ // SECURITY: Don't log command content - may contain sensitive data
388
+ console.log(chalk_1.default.cyan(`[Claude] Input received (${data.command.length} chars)`));
389
+ await tools_1.claudeProcessManager.sendInput(data.terminalSessionId, data.command);
390
+ return;
391
+ }
392
+ // Regular terminal command
393
+ // SECURITY: Don't log command content - may contain passwords, API keys, etc.
394
+ console.log(chalk_1.default.blue(`[Terminal] Executing command (${data.command.length} chars)`));
395
+ try {
396
+ const result = await terminal_1.terminalManager.executeCommand(data.terminalSessionId, data.command);
397
+ console.log(chalk_1.default.dim(`[Terminal] Exit code: ${result.exitCode}`));
398
+ }
399
+ catch (error) {
400
+ console.error(chalk_1.default.red(`[Terminal] Error: ${error.message}`));
401
+ }
402
+ });
403
+ websocket_1.wsClient.on('approval_response', (data) => {
404
+ console.log(chalk_1.default.blue(`[Approval] ${data.status}: ${data.approvalId}`));
405
+ approval_1.approvalManager.handleApprovalResponse(data.approvalId, data.status);
406
+ });
407
+ // Set up Claude session detection
408
+ if (tools_1.claudeSessionDetector.isClaudeInstalled()) {
409
+ console.log(chalk_1.default.cyan('[Claude] Scanning for Claude sessions...'));
410
+ // Attach event listeners BEFORE starting to watch (so we catch initial events)
411
+ tools_1.claudeSessionDetector.on('session_detected', (session) => {
412
+ console.log(chalk_1.default.cyan(`[Claude] New session detected: ${session.directory}`));
413
+ websocket_1.wsClient.sendClaudeSessionUpdate(session);
414
+ });
415
+ tools_1.claudeSessionDetector.on('session_changed', (session) => {
416
+ console.log(chalk_1.default.dim(`[Claude] Session updated: ${session.directory} (${session.state})`));
417
+ websocket_1.wsClient.sendClaudeSessionUpdate(session);
418
+ });
419
+ tools_1.claudeSessionDetector.on('claude_running_changed', (isRunning) => {
420
+ console.log(chalk_1.default.cyan(`[Claude] Claude is now ${isRunning ? 'ACTIVE' : 'inactive'}`));
421
+ websocket_1.wsClient.sendToolStatusUpdate('claude_code', isRunning ? 'active' : 'inactive');
422
+ });
423
+ // Scan and report existing sessions
424
+ const sessions = tools_1.claudeSessionDetector.scanSessions();
425
+ if (sessions.length > 0) {
426
+ console.log(chalk_1.default.cyan(`[Claude] Found ${sessions.length} session(s)`));
427
+ // Update session states based on file modification time before sending
428
+ const now = Date.now();
429
+ let hasActiveSession = false;
430
+ for (const session of sessions) {
431
+ const sessionTime = new Date(session.lastUsedAt).getTime();
432
+ if (now - sessionTime < 60000) {
433
+ session.state = 'active';
434
+ session.lastUsedAt = new Date().toISOString(); // Update to NOW for active sessions
435
+ hasActiveSession = true;
436
+ }
437
+ }
438
+ websocket_1.wsClient.sendClaudeSessions(sessions);
439
+ if (hasActiveSession) {
440
+ console.log(chalk_1.default.cyan(`[Claude] Claude is now ACTIVE`));
441
+ websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'active');
442
+ }
443
+ }
444
+ // Start watching for session changes
445
+ tools_1.claudeSessionDetector.startWatching(5000);
446
+ }
447
+ // Log approval events
448
+ approval_1.approvalManager.on('approved', (approval) => {
449
+ console.log(chalk_1.default.green(`[Approval] Approved: ${approval.description}`));
450
+ });
451
+ approval_1.approvalManager.on('rejected', (approval) => {
452
+ console.log(chalk_1.default.red(`[Approval] Rejected: ${approval.description}`));
453
+ });
454
+ websocket_1.wsClient.on('git_clone', async (data) => {
455
+ console.log(chalk_1.default.blue(`[Git] Clone request: ${data.repo.fullName}`));
456
+ try {
457
+ const result = await terminal_1.terminalManager.executeCommand(`git-clone-${Date.now()}`, data.command);
458
+ console.log(chalk_1.default.green(`[Git] Clone completed with exit code: ${result.exitCode}`));
459
+ }
460
+ catch (error) {
461
+ console.error(chalk_1.default.red(`[Git] Clone failed: ${error.message}`));
462
+ }
463
+ });
464
+ // Handle Claude start session request from mobile
465
+ websocket_1.wsClient.on('claude_start_session', async (data) => {
466
+ console.log(chalk_1.default.cyan(`[Claude] Start session request: ${data.directory}`));
467
+ try {
468
+ const result = await tools_1.claudeProcessManager.startSession(data.directory, data.terminalSessionId);
469
+ websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'active');
470
+ websocket_1.wsClient.sendTerminalCwd({ terminalSessionId: data.terminalSessionId, cwd: result.cwd });
471
+ console.log(chalk_1.default.green(`[Claude] Session started: ${data.terminalSessionId}`));
472
+ }
473
+ catch (error) {
474
+ console.error(chalk_1.default.red(`[Claude] Failed to start: ${error.message}`));
475
+ }
476
+ });
477
+ // Handle Claude resume session request from mobile
478
+ // NOTE: This is called when mobile opens a session view - we DON'T spawn Claude here
479
+ // Claude is only spawned when the user actually sends a message (via user_message event)
480
+ // This prevents duplicate transcript entries from double spawns
481
+ websocket_1.wsClient.on('claude_resume_session', async (data) => {
482
+ console.log(chalk_1.default.cyan(`[Claude] Resume session request: ${data.sessionKey} in ${data.directory}`));
483
+ // Resolve the directory path
484
+ let resolvedDir = data.directory;
485
+ if (resolvedDir === '~' || resolvedDir.startsWith('~/')) {
486
+ resolvedDir = resolvedDir === '~' ? os.homedir() : resolvedDir.replace('~', os.homedir());
487
+ }
488
+ resolvedDir = path.resolve(resolvedDir);
489
+ // Register session info for later use when message is sent (don't spawn yet)
490
+ tools_1.claudeProcessManager.registerSession(data.sessionKey, resolvedDir, data.terminalSessionId);
491
+ websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'active');
492
+ websocket_1.wsClient.sendClaudeSessionUpdate({
493
+ sessionKey: data.sessionKey,
494
+ directory: data.directory,
495
+ state: 'active',
496
+ lastUsedAt: new Date().toISOString(),
497
+ });
498
+ websocket_1.wsClient.sendTerminalCwd({ terminalSessionId: data.terminalSessionId, cwd: resolvedDir });
499
+ console.log(chalk_1.default.green(`[Claude] Session ready (will spawn on first message): ${data.sessionKey}`));
500
+ });
501
+ // Handle directory listing requests
502
+ websocket_1.wsClient.on('directory_list', async (data) => {
503
+ console.log(chalk_1.default.dim(`[Dir] Listing request received`));
504
+ try {
505
+ let resolvedPath = data.path;
506
+ if (resolvedPath === '~' || resolvedPath.startsWith('~/')) {
507
+ resolvedPath = resolvedPath === '~' ? os.homedir() : resolvedPath.replace('~', os.homedir());
508
+ }
509
+ // SECURITY: Normalize and validate path to prevent traversal attacks
510
+ resolvedPath = path.resolve(resolvedPath);
511
+ const homeDir = os.homedir();
512
+ // SECURITY: Only allow access to directories under home directory
513
+ // This prevents accessing sensitive system files like /etc/passwd
514
+ if (!resolvedPath.startsWith(homeDir)) {
515
+ console.warn(chalk_1.default.yellow(`[Dir] Access denied - path outside home directory: ${resolvedPath}`));
516
+ websocket_1.wsClient.sendDirectoryListResponse({ requestId: data.requestId, entries: [], currentPath: data.path });
517
+ return;
518
+ }
519
+ const entries = fs.readdirSync(resolvedPath, { withFileTypes: true })
520
+ .filter(entry => !entry.name.startsWith('.'))
521
+ .map(entry => ({
522
+ name: entry.name,
523
+ type: entry.isDirectory() ? 'directory' : 'file',
524
+ path: path.join(resolvedPath, entry.name),
525
+ }))
526
+ .sort((a, b) => {
527
+ if (a.type !== b.type)
528
+ return a.type === 'directory' ? -1 : 1;
529
+ return a.name.localeCompare(b.name);
530
+ });
531
+ websocket_1.wsClient.sendDirectoryListResponse({ requestId: data.requestId, entries, currentPath: resolvedPath });
532
+ }
533
+ catch (error) {
534
+ console.error(chalk_1.default.red(`[Dir] Error: ${error.message}`));
535
+ websocket_1.wsClient.sendDirectoryListResponse({ requestId: data.requestId, entries: [], currentPath: data.path });
536
+ }
537
+ });
538
+ // Handle transcript fetch requests from mobile
539
+ websocket_1.wsClient.on('transcript_fetch', async (data) => {
540
+ console.log(chalk_1.default.dim(`[Transcript] Fetching: ${data.sessionKey}, offset: ${data.offset}, limit: ${data.limit}, reverse: ${data.reverse}`));
541
+ try {
542
+ const result = await transcript_streamer_1.transcriptStreamer.fetchHistory(data.transcriptPath, data.offset || 0, data.limit || 100, data.reverse !== false // Default to true (most recent first)
543
+ );
544
+ websocket_1.wsClient.sendTranscriptHistory({
545
+ sessionKey: data.sessionKey,
546
+ ...result,
547
+ offset: data.offset || 0,
548
+ });
549
+ }
550
+ catch (error) {
551
+ console.error(chalk_1.default.red(`[Transcript] Error: ${error.message}`));
552
+ }
553
+ });
554
+ // Handle transcript subscribe
555
+ websocket_1.wsClient.on('transcript_subscribe', (data) => {
556
+ console.log(chalk_1.default.dim(`[Transcript] Subscribing: ${data.sessionKey}`));
557
+ transcript_streamer_1.transcriptStreamer.subscribeToUpdates(data.sessionKey, data.transcriptPath);
558
+ });
559
+ // Handle transcript unsubscribe
560
+ websocket_1.wsClient.on('transcript_unsubscribe', (data) => {
561
+ console.log(chalk_1.default.dim(`[Transcript] Unsubscribing: ${data.sessionKey}`));
562
+ transcript_streamer_1.transcriptStreamer.unsubscribeFromUpdates(data.sessionKey);
563
+ });
564
+ // Handle SDK subscribe start - mobile wants live updates for a session
565
+ // This is sent by API when mobile uses transcript_subscribe_sdk
566
+ websocket_1.wsClient.on('transcript_subscribe_sdk_start', async (data) => {
567
+ console.log(chalk_1.default.cyan(`[Transcript] SDK subscribe start: ${data.sessionKey}`));
568
+ // Find the transcript file for this session
569
+ const sessions = tools_1.claudeSessionDetector.scanSessions();
570
+ const session = sessions.find(s => s.sessionKey === data.sessionKey);
571
+ if (session?.transcriptPath) {
572
+ console.log(chalk_1.default.dim(`[Transcript] Starting watch for: ${session.transcriptPath}`));
573
+ transcript_streamer_1.transcriptStreamer.subscribeToUpdates(data.sessionKey, session.transcriptPath);
574
+ }
575
+ else {
576
+ console.log(chalk_1.default.yellow(`[Transcript] No transcript found for session: ${data.sessionKey}`));
577
+ }
578
+ });
579
+ // Handle claude sessions request - mobile app wants current sessions
580
+ websocket_1.wsClient.on('claude_sessions_request', () => {
581
+ console.log(chalk_1.default.cyan(`[Claude] Sessions requested by mobile`));
582
+ if (tools_1.claudeSessionDetector.isClaudeInstalled()) {
583
+ const sessions = tools_1.claudeSessionDetector.scanSessions();
584
+ const now = Date.now();
585
+ let hasActiveSession = false;
586
+ // Update session states based on file modification time
587
+ for (const session of sessions) {
588
+ const sessionTime = new Date(session.lastUsedAt).getTime();
589
+ if (now - sessionTime < 60000) {
590
+ session.state = 'active';
591
+ session.lastUsedAt = new Date().toISOString(); // Update to NOW for active sessions
592
+ hasActiveSession = true;
593
+ }
594
+ }
595
+ // Send sessions
596
+ if (sessions.length > 0) {
597
+ websocket_1.wsClient.sendClaudeSessions(sessions);
598
+ }
599
+ // Send tool status
600
+ websocket_1.wsClient.sendToolStatusUpdate('claude_code', hasActiveSession ? 'active' : 'inactive');
601
+ }
602
+ });
603
+ // Handle RPC requests from the API gateway
604
+ websocket_1.wsClient.on('rpc_request', async (data) => {
605
+ console.log(chalk_1.default.cyan(`[RPC] Request: ${data.method}, requestId: ${data.requestId}`));
606
+ try {
607
+ if (data.method === 'get_session_history') {
608
+ const { claudeSessionId, sessionKey, limit = 400, offset = 0 } = data.params;
609
+ console.log(chalk_1.default.dim(`[RPC] get_session_history request received`));
610
+ // Find the transcript file
611
+ let transcriptPath;
612
+ // If claudeSessionId is provided, search for the JSONL file directly
613
+ if (claudeSessionId) {
614
+ // SECURITY: Validate claudeSessionId to prevent path traversal
615
+ // Session IDs should be alphanumeric with hyphens/underscores only
616
+ const sessionIdRegex = /^[a-zA-Z0-9_-]+$/;
617
+ if (!sessionIdRegex.test(claudeSessionId)) {
618
+ console.warn(chalk_1.default.yellow(`[RPC] Invalid claudeSessionId format - rejected`));
619
+ websocket_1.wsClient.sendRpcResponse({
620
+ requestId: data.requestId,
621
+ error: { code: -32602, message: 'Invalid session ID format' }
622
+ });
623
+ return;
624
+ }
625
+ const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
626
+ if (fs.existsSync(claudeProjectsDir)) {
627
+ const projectDirs = fs.readdirSync(claudeProjectsDir);
628
+ for (const projectDir of projectDirs) {
629
+ // SECURITY: Validate projectDir as well to prevent traversal
630
+ if (!sessionIdRegex.test(projectDir) && !/^[a-zA-Z0-9_.-]+$/.test(projectDir)) {
631
+ continue;
632
+ }
633
+ const potentialPath = path.join(claudeProjectsDir, projectDir, `${claudeSessionId}.jsonl`);
634
+ // SECURITY: Verify the resolved path is still under claudeProjectsDir
635
+ const resolvedPotentialPath = path.resolve(potentialPath);
636
+ if (!resolvedPotentialPath.startsWith(claudeProjectsDir)) {
637
+ continue;
638
+ }
639
+ if (fs.existsSync(potentialPath)) {
640
+ transcriptPath = potentialPath;
641
+ console.log(chalk_1.default.dim(`[RPC] Found JSONL transcript`));
642
+ break;
643
+ }
644
+ }
645
+ }
646
+ }
647
+ // If not found by claudeSessionId, search all sessions
648
+ if (!transcriptPath && tools_1.claudeSessionDetector.isClaudeInstalled()) {
649
+ const sessions = tools_1.claudeSessionDetector.scanSessions();
650
+ // First try to match by sessionKey
651
+ let session = sessions.find(s => s.sessionKey === sessionKey);
652
+ // If no match by sessionKey, try to find most recent session for the same directory
653
+ if (!session && sessions.length > 0) {
654
+ // Just use the most recent session as fallback
655
+ session = sessions[0];
656
+ console.log(chalk_1.default.dim(`[RPC] Using most recent session as fallback: ${session.sessionKey}`));
657
+ }
658
+ if (session?.transcriptPath) {
659
+ transcriptPath = session.transcriptPath;
660
+ console.log(chalk_1.default.dim(`[RPC] Found transcript path: ${transcriptPath}`));
661
+ }
662
+ }
663
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
664
+ console.log(chalk_1.default.yellow(`[RPC] No transcript file found for session`));
665
+ websocket_1.wsClient.sendRpcResponse({
666
+ requestId: data.requestId,
667
+ result: { entries: [], totalEntries: 0, hasMore: false }
668
+ });
669
+ return;
670
+ }
671
+ // Read the transcript file
672
+ const result = await transcript_streamer_1.transcriptStreamer.fetchHistory(transcriptPath, offset, limit, true);
673
+ console.log(chalk_1.default.green(`[RPC] Loaded ${result.entries.length} entries from transcript`));
674
+ // IMPORTANT: Start watching this transcript for live updates
675
+ // This is needed because SDK mode doesn't send transcript_subscribe
676
+ // Use sessionKey from mobile for updates (must match what mobile is listening for)
677
+ const updateSessionKey = sessionKey;
678
+ if (!updateSessionKey) {
679
+ console.log(chalk_1.default.yellow(`[RPC] No sessionKey provided, using claudeSessionId - updates may not route correctly`));
680
+ }
681
+ const watchKey = updateSessionKey || claudeSessionId || data.requestId;
682
+ console.log(chalk_1.default.cyan(`[RPC] Starting file watch for live updates: ${watchKey}`));
683
+ transcript_streamer_1.transcriptStreamer.subscribeToUpdates(watchKey, transcriptPath);
684
+ websocket_1.wsClient.sendRpcResponse({
685
+ requestId: data.requestId,
686
+ result: {
687
+ entries: result.entries,
688
+ totalEntries: result.totalEntries,
689
+ hasMore: result.hasMore,
690
+ sessionKey: watchKey, // Tell mobile which sessionKey to listen for updates
691
+ }
692
+ });
693
+ }
694
+ else {
695
+ console.log(chalk_1.default.yellow(`[RPC] Unknown method: ${data.method}`));
696
+ websocket_1.wsClient.sendRpcResponse({
697
+ requestId: data.requestId,
698
+ error: { code: -32601, message: `Method not found: ${data.method}` }
699
+ });
700
+ }
701
+ }
702
+ catch (error) {
703
+ console.error(chalk_1.default.red(`[RPC] Error handling ${data.method}:`, error.message));
704
+ websocket_1.wsClient.sendRpcResponse({
705
+ requestId: data.requestId,
706
+ error: { code: -32603, message: error.message || 'Internal error' }
707
+ });
708
+ }
709
+ });
710
+ // Forward live transcript updates to WebSocket
711
+ transcript_streamer_1.transcriptStreamer.on('update', (data) => {
712
+ console.log(chalk_1.default.green(`[Transcript] Sending update for ${data.sessionKey}: ${data.entry?.type}`));
713
+ websocket_1.wsClient.sendTranscriptUpdate(data);
714
+ // Also update session lastUsedAt to keep it fresh
715
+ // Find the session to get the directory
716
+ const sessions = tools_1.claudeSessionDetector.getSessions();
717
+ const session = sessions.find(s => s.sessionKey === data.sessionKey);
718
+ if (session) {
719
+ websocket_1.wsClient.sendClaudeSessionUpdate({
720
+ sessionKey: data.sessionKey,
721
+ directory: session.directory,
722
+ state: 'active',
723
+ lastUsedAt: new Date().toISOString(),
724
+ transcriptPath: session.transcriptPath,
725
+ });
726
+ }
727
+ });
728
+ // Forward Claude process output to WebSocket
729
+ tools_1.claudeProcessManager.on('output', (data) => {
730
+ websocket_1.wsClient.sendTerminalOutput(data);
731
+ });
732
+ // Forward Claude approval requests to WebSocket (mobile approval)
733
+ tools_1.claudeProcessManager.on('claude_approval_request', (data) => {
734
+ console.log(chalk_1.default.yellow(`[Claude] Approval request: ${data.approvalId}`));
735
+ websocket_1.wsClient.sendClaudeApprovalRequest(data);
736
+ });
737
+ // Handle Claude approval responses from mobile
738
+ websocket_1.wsClient.on('claude_approval_response', (data) => {
739
+ console.log(chalk_1.default.green(`[Claude] Approval response: ${data.approvalId} -> ${data.response}`));
740
+ tools_1.claudeProcessManager.handleApprovalResponse(data.approvalId, data.response);
741
+ });
742
+ // Handle user messages from mobile app (send to Claude session)
743
+ websocket_1.wsClient.on('user_message', async (data) => {
744
+ // SECURITY: Don't log message content - may contain sensitive prompts
745
+ console.log(chalk_1.default.cyan(`[Claude] User message received (${data.message.length} chars)`));
746
+ // The session should have been registered via claude_resume_session
747
+ // sendInput will spawn the process if needed using the registered session info
748
+ const terminalSessionId = data.sessionKey; // Use sessionKey as terminalSessionId
749
+ if (!terminalSessionId) {
750
+ console.log(chalk_1.default.yellow(`[Claude] No sessionKey provided in user_message`));
751
+ return;
752
+ }
753
+ // Check if this session is registered (either active or registered for later spawn)
754
+ if (!tools_1.claudeProcessManager.isClaudeSession(terminalSessionId)) {
755
+ console.log(chalk_1.default.yellow(`[Claude] Session not registered: ${terminalSessionId}`));
756
+ console.log(chalk_1.default.dim(`[Claude] Hint: Mobile should send claude_resume_session first`));
757
+ return;
758
+ }
759
+ console.log(chalk_1.default.dim(`[Claude] Sending to session: ${terminalSessionId}`));
760
+ const sent = await tools_1.claudeProcessManager.sendInput(terminalSessionId, data.message + '\n');
761
+ if (!sent) {
762
+ console.log(chalk_1.default.yellow(`[Claude] Failed to send message - session may need restart`));
763
+ }
764
+ });
765
+ // Handle Claude process end
766
+ tools_1.claudeProcessManager.on('session_ended', (data) => {
767
+ console.log(chalk_1.default.dim(`[Claude] Session ended: ${data.terminalSessionId}`));
768
+ websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'inactive');
769
+ if (data.sessionKey) {
770
+ websocket_1.wsClient.sendClaudeSessionUpdate({
771
+ sessionKey: data.sessionKey,
772
+ directory: data.directory,
773
+ state: 'inactive',
774
+ lastUsedAt: new Date().toISOString(),
775
+ });
776
+ }
777
+ });
778
+ // Forward thinking content to mobile
779
+ tools_1.claudeProcessManager.on('thinking_content', (data) => {
780
+ if (data.content || !data.partial) {
781
+ console.log(chalk_1.default.magenta(`[Claude] Thinking${data.partial ? ' (streaming)' : ' (complete)'}: ${data.content?.substring(0, 50) || '...'}`));
782
+ }
783
+ websocket_1.wsClient.sendThinkingContent({
784
+ sessionKey: data.sessionKey,
785
+ thinkingId: data.thinkingId,
786
+ content: data.content,
787
+ partial: data.partial,
788
+ });
789
+ });
790
+ // Forward token usage to mobile
791
+ tools_1.claudeProcessManager.on('token_usage', (data) => {
792
+ console.log(chalk_1.default.blue(`[Claude] Tokens: ${data.usage.inputTokens} in / ${data.usage.outputTokens} out`));
793
+ websocket_1.wsClient.sendTokenUsage({
794
+ sessionKey: data.sessionKey,
795
+ usage: data.usage,
796
+ });
797
+ });
798
+ // Forward task progress to mobile
799
+ tools_1.claudeProcessManager.on('task_progress', (data) => {
800
+ if (data.type === 'list') {
801
+ console.log(chalk_1.default.cyan(`[Claude] Task list: ${data.tasks?.length || 0} tasks`));
802
+ }
803
+ else {
804
+ console.log(chalk_1.default.cyan(`[Claude] Task ${data.type}: ${data.task?.subject || data.task?.id}`));
805
+ }
806
+ websocket_1.wsClient.sendTaskProgress({
807
+ sessionKey: data.sessionKey,
808
+ type: data.type,
809
+ task: data.task,
810
+ tasks: data.tasks,
811
+ });
812
+ });
813
+ websocket_1.wsClient.on('disconnected', (reason) => {
814
+ console.log(chalk_1.default.yellow(`\nDisconnected: ${reason}`));
815
+ if (reason !== 'io client disconnect') {
816
+ console.log(chalk_1.default.dim('Attempting to reconnect...'));
817
+ }
818
+ });
819
+ websocket_1.wsClient.on('error', (error) => {
820
+ console.error(chalk_1.default.red(`Connection error: ${error.message}`));
821
+ });
822
+ // Keep the process running
823
+ process.on('SIGINT', () => {
824
+ console.log(chalk_1.default.yellow('\nDisconnecting...'));
825
+ tools_1.claudeSessionDetector.stopWatching();
826
+ transcript_streamer_1.transcriptStreamer.cleanup();
827
+ websocket_1.wsClient.disconnect();
828
+ process.exit(0);
829
+ });
830
+ // Keep alive
831
+ await new Promise(() => { });
832
+ }
833
+ catch (error) {
834
+ spinner.fail('Failed to connect');
835
+ console.error(chalk_1.default.red(error.message || 'Unknown error'));
836
+ }
837
+ }
838
+ // Helper function to wait for pairing
839
+ async function waitForPairing(deviceId) {
840
+ return new Promise((resolve, reject) => {
841
+ const checkInterval = setInterval(async () => {
842
+ try {
843
+ const status = await api_1.api.checkPairingStatus(deviceId);
844
+ if (status.isPaired) {
845
+ clearInterval(checkInterval);
846
+ config_1.config.userId = status.userId;
847
+ config_1.config.pairedAt = new Date().toISOString();
848
+ console.log(chalk_1.default.green('\n✓ Device paired successfully!'));
849
+ console.log(chalk_1.default.dim('\nConnecting to receive commands...\n'));
850
+ // Auto-connect after successful pairing
851
+ resolve();
852
+ }
853
+ }
854
+ catch (error) {
855
+ // Continue waiting
856
+ }
857
+ }, 2000);
858
+ // Handle Ctrl+C
859
+ process.on('SIGINT', () => {
860
+ clearInterval(checkInterval);
861
+ console.log(chalk_1.default.yellow('\nPairing cancelled.'));
862
+ process.exit(0);
863
+ });
864
+ });
865
+ }
866
+ // Run the CLI
867
+ program.parse();
868
+ //# sourceMappingURL=index.js.map