forkoff 1.0.17 → 1.0.19

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 (158) hide show
  1. package/LICENSE +11 -7
  2. package/README.md +77 -118
  3. package/dist/approval.d.ts +1 -0
  4. package/dist/approval.js +9 -0
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +62 -16
  7. package/dist/crypto/e2eeManager.d.ts +49 -52
  8. package/dist/crypto/e2eeManager.js +256 -181
  9. package/dist/crypto/encryption.d.ts +8 -10
  10. package/dist/crypto/encryption.js +29 -94
  11. package/dist/crypto/index.d.ts +10 -0
  12. package/dist/crypto/index.js +22 -0
  13. package/dist/crypto/keyExchange.d.ts +6 -20
  14. package/dist/crypto/keyExchange.js +18 -110
  15. package/dist/crypto/keyGeneration.d.ts +2 -13
  16. package/dist/crypto/keyGeneration.js +14 -88
  17. package/dist/crypto/keyStorage.d.ts +32 -5
  18. package/dist/crypto/keyStorage.js +152 -8
  19. package/dist/crypto/sessionPersistence.d.ts +7 -13
  20. package/dist/crypto/sessionPersistence.js +108 -33
  21. package/dist/crypto/types.d.ts +24 -3
  22. package/dist/crypto/types.js +2 -1
  23. package/dist/crypto/websocketE2EE.d.ts +6 -17
  24. package/dist/crypto/websocketE2EE.js +21 -38
  25. package/dist/index.js +203 -280
  26. package/dist/integration.d.ts +0 -1
  27. package/dist/integration.js +2 -4
  28. package/dist/logger.d.ts +15 -0
  29. package/dist/logger.js +209 -1
  30. package/dist/server.d.ts +30 -0
  31. package/dist/server.js +162 -0
  32. package/dist/startup.js +15 -6
  33. package/dist/terminal.d.ts +1 -0
  34. package/dist/terminal.js +94 -1
  35. package/dist/tools/claude-process.d.ts +8 -0
  36. package/dist/tools/claude-process.js +199 -26
  37. package/dist/tools/claude-sessions.d.ts +1 -0
  38. package/dist/tools/claude-sessions.js +36 -10
  39. package/dist/tools/detector.js +11 -3
  40. package/dist/tools/permission-hook.js +94 -27
  41. package/dist/tools/permission-ipc.d.ts +1 -0
  42. package/dist/tools/permission-ipc.js +61 -14
  43. package/dist/transcript-streamer.d.ts +1 -0
  44. package/dist/transcript-streamer.js +18 -4
  45. package/dist/usage-tracker.d.ts +45 -0
  46. package/dist/usage-tracker.js +243 -0
  47. package/dist/websocket.d.ts +43 -12
  48. package/dist/websocket.js +418 -214
  49. package/package.json +5 -4
  50. package/dist/__tests__/cli-commands.test.d.ts +0 -6
  51. package/dist/__tests__/cli-commands.test.d.ts.map +0 -1
  52. package/dist/__tests__/cli-commands.test.js +0 -213
  53. package/dist/__tests__/cli-commands.test.js.map +0 -1
  54. package/dist/__tests__/crypto/e2e-integration.test.d.ts +0 -17
  55. package/dist/__tests__/crypto/e2e-integration.test.d.ts.map +0 -1
  56. package/dist/__tests__/crypto/e2e-integration.test.js +0 -338
  57. package/dist/__tests__/crypto/e2e-integration.test.js.map +0 -1
  58. package/dist/__tests__/crypto/e2eeManager.test.d.ts +0 -2
  59. package/dist/__tests__/crypto/e2eeManager.test.d.ts.map +0 -1
  60. package/dist/__tests__/crypto/e2eeManager.test.js +0 -242
  61. package/dist/__tests__/crypto/e2eeManager.test.js.map +0 -1
  62. package/dist/__tests__/crypto/encryption.test.d.ts +0 -2
  63. package/dist/__tests__/crypto/encryption.test.d.ts.map +0 -1
  64. package/dist/__tests__/crypto/encryption.test.js +0 -116
  65. package/dist/__tests__/crypto/encryption.test.js.map +0 -1
  66. package/dist/__tests__/crypto/keyExchange.test.d.ts +0 -2
  67. package/dist/__tests__/crypto/keyExchange.test.d.ts.map +0 -1
  68. package/dist/__tests__/crypto/keyExchange.test.js +0 -84
  69. package/dist/__tests__/crypto/keyExchange.test.js.map +0 -1
  70. package/dist/__tests__/crypto/keyGeneration.test.d.ts +0 -2
  71. package/dist/__tests__/crypto/keyGeneration.test.d.ts.map +0 -1
  72. package/dist/__tests__/crypto/keyGeneration.test.js +0 -61
  73. package/dist/__tests__/crypto/keyGeneration.test.js.map +0 -1
  74. package/dist/__tests__/crypto/keyStorage.test.d.ts +0 -2
  75. package/dist/__tests__/crypto/keyStorage.test.d.ts.map +0 -1
  76. package/dist/__tests__/crypto/keyStorage.test.js +0 -133
  77. package/dist/__tests__/crypto/keyStorage.test.js.map +0 -1
  78. package/dist/__tests__/crypto/websocketIntegration.test.d.ts +0 -2
  79. package/dist/__tests__/crypto/websocketIntegration.test.d.ts.map +0 -1
  80. package/dist/__tests__/crypto/websocketIntegration.test.js +0 -259
  81. package/dist/__tests__/crypto/websocketIntegration.test.js.map +0 -1
  82. package/dist/__tests__/startup.test.d.ts +0 -11
  83. package/dist/__tests__/startup.test.d.ts.map +0 -1
  84. package/dist/__tests__/startup.test.js +0 -241
  85. package/dist/__tests__/startup.test.js.map +0 -1
  86. package/dist/__tests__/tools/claude-process.test.d.ts +0 -8
  87. package/dist/__tests__/tools/claude-process.test.d.ts.map +0 -1
  88. package/dist/__tests__/tools/claude-process.test.js +0 -430
  89. package/dist/__tests__/tools/claude-process.test.js.map +0 -1
  90. package/dist/__tests__/tools/permission-hook.test.d.ts +0 -17
  91. package/dist/__tests__/tools/permission-hook.test.d.ts.map +0 -1
  92. package/dist/__tests__/tools/permission-hook.test.js +0 -616
  93. package/dist/__tests__/tools/permission-hook.test.js.map +0 -1
  94. package/dist/__tests__/tools/permission-ipc.test.d.ts +0 -11
  95. package/dist/__tests__/tools/permission-ipc.test.d.ts.map +0 -1
  96. package/dist/__tests__/tools/permission-ipc.test.js +0 -612
  97. package/dist/__tests__/tools/permission-ipc.test.js.map +0 -1
  98. package/dist/__tests__/websocket.test.d.ts +0 -13
  99. package/dist/__tests__/websocket.test.d.ts.map +0 -1
  100. package/dist/__tests__/websocket.test.js +0 -204
  101. package/dist/__tests__/websocket.test.js.map +0 -1
  102. package/dist/api.d.ts +0 -44
  103. package/dist/api.d.ts.map +0 -1
  104. package/dist/api.js +0 -76
  105. package/dist/api.js.map +0 -1
  106. package/dist/approval.d.ts.map +0 -1
  107. package/dist/approval.js.map +0 -1
  108. package/dist/config.d.ts.map +0 -1
  109. package/dist/config.js.map +0 -1
  110. package/dist/crypto/e2eeManager.d.ts.map +0 -1
  111. package/dist/crypto/e2eeManager.js.map +0 -1
  112. package/dist/crypto/encryption.d.ts.map +0 -1
  113. package/dist/crypto/encryption.js.map +0 -1
  114. package/dist/crypto/keyExchange.d.ts.map +0 -1
  115. package/dist/crypto/keyExchange.js.map +0 -1
  116. package/dist/crypto/keyGeneration.d.ts.map +0 -1
  117. package/dist/crypto/keyGeneration.js.map +0 -1
  118. package/dist/crypto/keyStorage.d.ts.map +0 -1
  119. package/dist/crypto/keyStorage.js.map +0 -1
  120. package/dist/crypto/sessionPersistence.d.ts.map +0 -1
  121. package/dist/crypto/sessionPersistence.js.map +0 -1
  122. package/dist/crypto/types.d.ts.map +0 -1
  123. package/dist/crypto/types.js.map +0 -1
  124. package/dist/crypto/websocketE2EE.d.ts.map +0 -1
  125. package/dist/crypto/websocketE2EE.js.map +0 -1
  126. package/dist/index.d.ts.map +0 -1
  127. package/dist/index.js.map +0 -1
  128. package/dist/integration.d.ts.map +0 -1
  129. package/dist/integration.js.map +0 -1
  130. package/dist/logger.d.ts.map +0 -1
  131. package/dist/logger.js.map +0 -1
  132. package/dist/startup.d.ts.map +0 -1
  133. package/dist/startup.js.map +0 -1
  134. package/dist/terminal.d.ts.map +0 -1
  135. package/dist/terminal.js.map +0 -1
  136. package/dist/tools/__tests__/claude-sessions.test.d.ts +0 -2
  137. package/dist/tools/__tests__/claude-sessions.test.d.ts.map +0 -1
  138. package/dist/tools/__tests__/claude-sessions.test.js +0 -306
  139. package/dist/tools/__tests__/claude-sessions.test.js.map +0 -1
  140. package/dist/tools/claude-hooks.d.ts.map +0 -1
  141. package/dist/tools/claude-hooks.js.map +0 -1
  142. package/dist/tools/claude-process.d.ts.map +0 -1
  143. package/dist/tools/claude-process.js.map +0 -1
  144. package/dist/tools/claude-sessions.d.ts.map +0 -1
  145. package/dist/tools/claude-sessions.js.map +0 -1
  146. package/dist/tools/detector.d.ts.map +0 -1
  147. package/dist/tools/detector.js.map +0 -1
  148. package/dist/tools/index.d.ts.map +0 -1
  149. package/dist/tools/index.js.map +0 -1
  150. package/dist/tools/permission-hook.d.ts.map +0 -1
  151. package/dist/tools/permission-hook.js.map +0 -1
  152. package/dist/tools/permission-ipc.d.ts.map +0 -1
  153. package/dist/tools/permission-ipc.js.map +0 -1
  154. package/dist/transcript-streamer.d.ts.map +0 -1
  155. package/dist/transcript-streamer.js.map +0 -1
  156. package/dist/websocket.d.ts.map +0 -1
  157. package/dist/websocket.js.map +0 -1
  158. package/jest.config.js +0 -18
package/dist/index.js CHANGED
@@ -41,36 +41,53 @@ exports.createProgram = createProgram;
41
41
  const commander_1 = require("commander");
42
42
  const chalk_1 = __importDefault(require("chalk"));
43
43
  const qrcode_terminal_1 = __importDefault(require("qrcode-terminal"));
44
+ const crypto = __importStar(require("crypto"));
44
45
  const config_1 = require("./config");
45
- const api_1 = require("./api");
46
46
  const websocket_1 = require("./websocket");
47
47
  const terminal_1 = require("./terminal");
48
48
  const approval_1 = require("./approval");
49
49
  const tools_1 = require("./tools");
50
50
  const transcript_streamer_1 = require("./transcript-streamer");
51
51
  const logger_1 = require("./logger");
52
+ const usage_tracker_1 = require("./usage-tracker");
52
53
  const startup_1 = require("./startup");
53
54
  const fs = __importStar(require("fs"));
54
55
  const path = __importStar(require("path"));
55
56
  const os = __importStar(require("os"));
57
+ /** Get the local network IP (first non-internal IPv4 address) */
58
+ function getLocalIp() {
59
+ const interfaces = os.networkInterfaces();
60
+ for (const name of Object.keys(interfaces)) {
61
+ for (const iface of interfaces[name]) {
62
+ if (iface.family === 'IPv4' && !iface.internal) {
63
+ return iface.address;
64
+ }
65
+ }
66
+ }
67
+ return '127.0.0.1';
68
+ }
56
69
  function createProgram() {
57
70
  const program = new commander_1.Command();
58
71
  program
59
72
  .name('forkoff')
60
73
  .description('CLI tool for ForkOff - Connect your AI coding tools to mobile')
61
74
  .version(require('../package.json').version)
62
- .option('-q, --quiet', 'Suppress all output (for background operation)');
75
+ .option('-q, --quiet', 'Suppress all output (for background operation)')
76
+ .option('--debug', 'Enable debug logging to file (~/.forkoff-cli/logs/)');
63
77
  program.hook('preAction', () => {
78
+ if (program.opts().debug) {
79
+ (0, logger_1.setDebug)(true);
80
+ (0, logger_1.cleanupOldLogs)(10);
81
+ }
64
82
  if (program.opts().quiet) {
65
83
  (0, logger_1.setQuiet)(true);
66
84
  }
67
85
  });
68
- // Configure API/WS URLs
86
+ // Configure CLI settings
69
87
  program
70
88
  .command('config')
71
89
  .description('Configure ForkOff CLI settings')
72
- .option('-a, --api <url>', 'Set API URL')
73
- .option('-w, --ws <url>', 'Set WebSocket URL')
90
+ .option('-p, --port <port>', 'Set relay server port')
74
91
  .option('-n, --name <name>', 'Set device name')
75
92
  .option('--show', 'Show current configuration')
76
93
  .option('--reset', 'Reset all configuration')
@@ -80,22 +97,24 @@ function createProgram() {
80
97
  console.log(chalk_1.default.green('Configuration reset successfully'));
81
98
  return;
82
99
  }
83
- if (options.api) {
84
- config_1.config.apiUrl = options.api;
85
- console.log(chalk_1.default.green(`API URL set to: ${options.api}`));
86
- }
87
- if (options.ws) {
88
- config_1.config.wsUrl = options.ws;
89
- console.log(chalk_1.default.green(`WebSocket URL set to: ${options.ws}`));
100
+ if (options.port) {
101
+ const port = parseInt(options.port, 10);
102
+ if (isNaN(port) || port < 1 || port > 65535) {
103
+ console.log(chalk_1.default.red('Invalid port number. Must be between 1 and 65535.'));
104
+ return;
105
+ }
106
+ config_1.config.relayPort = port;
107
+ console.log(chalk_1.default.green(`Relay port set to: ${port}`));
90
108
  }
91
109
  if (options.name) {
92
110
  config_1.config.deviceName = options.name;
93
111
  console.log(chalk_1.default.green(`Device name set to: ${options.name}`));
94
112
  }
95
- if (options.show || (!options.api && !options.ws && !options.name && !options.reset)) {
113
+ if (options.show || (!options.port && !options.name && !options.reset)) {
114
+ const localIp = getLocalIp();
96
115
  console.log(chalk_1.default.bold('\nCurrent Configuration:'));
97
- console.log(` API URL: ${chalk_1.default.cyan(config_1.config.apiUrl)}`);
98
- console.log(` WebSocket: ${chalk_1.default.cyan(config_1.config.wsUrl)}`);
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))}`);
99
118
  console.log(` Device Name: ${chalk_1.default.cyan(config_1.config.deviceName)}`);
100
119
  console.log(` Device ID: ${chalk_1.default.cyan(config_1.config.deviceId || 'Not registered')}`);
101
120
  console.log(` Paired: ${config_1.config.isPaired ? chalk_1.default.green('Yes') : chalk_1.default.yellow('No')}`);
@@ -111,52 +130,39 @@ function createProgram() {
111
130
  .command('pair')
112
131
  .description('Generate pairing code to connect with mobile app')
113
132
  .action(async () => {
114
- const spinner = (0, logger_1.createSpinner)('Connecting to ForkOff server...').start();
133
+ const spinner = (0, logger_1.createSpinner)('Starting relay server...').start();
115
134
  try {
116
- // Check server health
117
- const isHealthy = await api_1.api.healthCheck();
118
- if (!isHealthy) {
119
- spinner.fail('Cannot connect to ForkOff server');
120
- console.log(chalk_1.default.yellow(`\nMake sure the server is running at ${config_1.config.apiUrl}`));
121
- console.log(chalk_1.default.dim('Use "forkoff config --api <url>" to change the server URL'));
122
- return;
123
- }
124
- spinner.text = 'Registering device...';
125
- // Register device or refresh pairing code
126
- let result;
127
- if (config_1.config.deviceId) {
128
- try {
129
- result = await api_1.api.refreshPairingCode(config_1.config.deviceId);
130
- }
131
- catch {
132
- // Device might not exist anymore, register fresh
133
- result = await api_1.api.registerDevice();
134
- }
135
- }
136
- else {
137
- result = await api_1.api.registerDevice();
138
- }
139
- // Save device info
140
- config_1.config.deviceId = result.device.id;
141
- config_1.config.pairingCode = result.pairingCode;
142
- spinner.succeed('Device registered successfully!\n');
135
+ // Ensure we have a deviceId
136
+ config_1.config.ensureDeviceId();
137
+ // Start embedded relay server
138
+ await websocket_1.wsClient.startServer(config_1.config.relayPort);
139
+ // Generate random 8-char pairing code
140
+ const pairingCode = crypto.randomBytes(4).toString('hex').toUpperCase().slice(0, 8);
141
+ 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`);
143
147
  // Display pairing info
144
148
  console.log(chalk_1.default.bold('Scan this QR code with the ForkOff mobile app:\n'));
145
- // Generate QR code with pairing URL
146
- const pairingUrl = `forkoff://pair/${result.pairingCode}`;
149
+ const pairingUrl = `forkoff://pair/${pairingCode}?relay=${encodeURIComponent(relayUrl)}`;
147
150
  qrcode_terminal_1.default.generate(pairingUrl, { small: true }, (code) => {
148
151
  console.log(code);
149
152
  });
150
153
  console.log(chalk_1.default.bold('\nOr enter this code manually:\n'));
151
- console.log(chalk_1.default.bgBlue.white.bold(` ${result.pairingCode} `));
154
+ console.log(chalk_1.default.bgBlue.white.bold(` ${pairingCode} `));
152
155
  console.log();
153
- const expiresAt = new Date(result.expiresAt);
154
- console.log(chalk_1.default.dim(`Code expires at: ${expiresAt.toLocaleTimeString()}`));
156
+ console.log(chalk_1.default.dim(`Relay: ${relayUrl}`));
155
157
  console.log();
156
158
  // Wait for pairing
157
159
  console.log(chalk_1.default.yellow('Waiting for mobile app to scan...'));
158
160
  console.log(chalk_1.default.dim('Press Ctrl+C to cancel\n'));
159
- await waitForPairing(result.device.id);
161
+ const pairData = await waitForPairing();
162
+ // Server already sent pair_device_ack to mobile
163
+ config_1.config.pairedAt = new Date().toISOString();
164
+ console.log(chalk_1.default.green('\n\u2713 Device paired successfully!'));
165
+ console.log(chalk_1.default.dim('\nStarting connection...\n'));
160
166
  // Auto-register startup if not explicitly disabled
161
167
  if (config_1.config.startupEnabled !== false) {
162
168
  try {
@@ -167,11 +173,11 @@ function createProgram() {
167
173
  // Non-critical — don't fail pairing over this
168
174
  }
169
175
  }
170
- // Auto-connect after successful pairing
176
+ // Continue to main connection (server already running)
171
177
  await startConnection();
172
178
  }
173
179
  catch (error) {
174
- spinner.fail('Failed to register device');
180
+ spinner.fail('Failed to pair device');
175
181
  console.error(chalk_1.default.red(error.message || 'Unknown error'));
176
182
  }
177
183
  });
@@ -184,29 +190,15 @@ function createProgram() {
184
190
  console.log(chalk_1.default.yellow('Device not registered. Run "forkoff pair" first.'));
185
191
  return;
186
192
  }
187
- const spinner = (0, logger_1.createSpinner)('Checking status...').start();
188
- try {
189
- const status = await api_1.api.checkPairingStatus(config_1.config.deviceId);
190
- spinner.stop();
191
- console.log(chalk_1.default.bold('\nDevice Status:'));
192
- console.log(` Device ID: ${chalk_1.default.cyan(config_1.config.deviceId)}`);
193
- console.log(` Device Name: ${chalk_1.default.cyan(config_1.config.deviceName)}`);
194
- console.log(` Paired: ${status.isPaired ? chalk_1.default.green('Yes') : chalk_1.default.yellow('No')}`);
195
- if (status.isPaired) {
196
- config_1.config.userId = status.userId;
197
- config_1.config.pairedAt = config_1.config.pairedAt || new Date().toISOString();
198
- console.log(` User ID: ${chalk_1.default.cyan(status.userId)}`);
199
- }
200
- if (websocket_1.wsClient.isConnected) {
201
- console.log(` WebSocket: ${chalk_1.default.green('Connected')}`);
202
- }
203
- else {
204
- console.log(` WebSocket: ${chalk_1.default.yellow('Disconnected')}`);
205
- }
206
- }
207
- catch (error) {
208
- spinner.fail('Failed to check status');
209
- console.error(chalk_1.default.red(error.message || 'Unknown error'));
193
+ const localIp = getLocalIp();
194
+ console.log(chalk_1.default.bold('\nDevice Status:'));
195
+ console.log(` Device ID: ${chalk_1.default.cyan(config_1.config.deviceId)}`);
196
+ console.log(` Device Name: ${chalk_1.default.cyan(config_1.config.deviceName)}`);
197
+ 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}`)}`);
199
+ console.log(` Mobile: ${websocket_1.wsClient.isConnected ? chalk_1.default.green('Connected') : chalk_1.default.yellow('Not connected')}`);
200
+ if (config_1.config.pairedAt) {
201
+ console.log(` Paired At: ${chalk_1.default.dim(config_1.config.pairedAt)}`);
210
202
  }
211
203
  });
212
204
  // Connect and stay online (for returning users who already paired)
@@ -232,6 +224,12 @@ function createProgram() {
232
224
  // Non-critical
233
225
  }
234
226
  }
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}`));
232
+ console.log(chalk_1.default.dim('Waiting for mobile app to connect...\n'));
235
233
  await startConnection();
236
234
  });
237
235
  // Disconnect/unpair device
@@ -247,7 +245,6 @@ function createProgram() {
247
245
  }
248
246
  catch { }
249
247
  }
250
- config_1.config.userId = null;
251
248
  config_1.config.pairedAt = null;
252
249
  config_1.config.pairingCode = null;
253
250
  console.log(chalk_1.default.green('Device disconnected and unpaired.'));
@@ -404,24 +401,17 @@ function createProgram() {
404
401
  console.error(chalk_1.default.red(error.message));
405
402
  }
406
403
  });
407
- // Helper function to start connection and set up event handlers
404
+ // Helper function to set up event handlers (server already started by caller)
408
405
  async function startConnection() {
409
- const spinner = (0, logger_1.createSpinner)('Connecting to ForkOff...').start();
406
+ const spinner = (0, logger_1.createSpinner)('Initializing...').start();
410
407
  try {
411
- await websocket_1.wsClient.connect();
412
408
  tools_1.PermissionIpcManager.cleanupStaleTempFiles();
413
- spinner.succeed('Connected to ForkOff!\n');
414
- // Detect and report connected tools
409
+ spinner.succeed('Ready!\n');
410
+ // Detect connected tools
415
411
  spinner.start('Detecting AI coding tools...');
416
412
  try {
417
413
  const toolResult = await tools_1.toolDetector.detectAll();
418
414
  if (toolResult.tools.length > 0) {
419
- const toolsToReport = toolResult.tools.map(tool => ({
420
- type: tool.type,
421
- name: tool.name,
422
- version: tool.version || null,
423
- }));
424
- await api_1.api.reportConnectedTools(config_1.config.deviceId, toolsToReport);
425
415
  spinner.succeed(`Detected ${toolResult.tools.length} AI tool(s): ${toolResult.tools.map(t => t.name).join(', ')}`);
426
416
  }
427
417
  else {
@@ -443,7 +433,7 @@ function createProgram() {
443
433
  });
444
434
  // When a session is auto-created (command received before terminal_create), send the cwd
445
435
  terminal_1.terminalManager.on('session_created', (data) => {
446
- console.log(chalk_1.default.dim(`[Terminal] Session auto-created: ${data.terminalSessionId} at ${data.cwd}`));
436
+ console.log(chalk_1.default.dim(`[Terminal] Session auto-created: ${data.terminalSessionId}`));
447
437
  websocket_1.wsClient.sendTerminalCwd({
448
438
  terminalSessionId: data.terminalSessionId,
449
439
  cwd: data.cwd,
@@ -465,7 +455,7 @@ function createProgram() {
465
455
  terminalSessionId: data.terminalSessionId,
466
456
  cwd: session.cwd,
467
457
  });
468
- console.log(chalk_1.default.dim(`[Terminal] Session created with cwd: ${session.cwd}`));
458
+ console.log(chalk_1.default.dim(`[Terminal] Session created`));
469
459
  });
470
460
  // Set up event handlers
471
461
  websocket_1.wsClient.on('terminal_command', async (data) => {
@@ -496,11 +486,11 @@ function createProgram() {
496
486
  console.log(chalk_1.default.cyan('[Claude] Scanning for Claude sessions...'));
497
487
  // Attach event listeners BEFORE starting to watch (so we catch initial events)
498
488
  tools_1.claudeSessionDetector.on('session_detected', (session) => {
499
- console.log(chalk_1.default.cyan(`[Claude] New session detected: ${session.directory}`));
489
+ console.log(chalk_1.default.cyan(`[Claude] New session detected`));
500
490
  websocket_1.wsClient.sendClaudeSessionUpdate(session);
501
491
  });
502
492
  tools_1.claudeSessionDetector.on('session_changed', (session) => {
503
- console.log(chalk_1.default.dim(`[Claude] Session updated: ${session.directory} (${session.state})`));
493
+ console.log(chalk_1.default.dim(`[Claude] Session updated (${session.state})`));
504
494
  websocket_1.wsClient.sendClaudeSessionUpdate(session);
505
495
  });
506
496
  tools_1.claudeSessionDetector.on('claude_running_changed', (isRunning) => {
@@ -535,18 +525,20 @@ function createProgram() {
535
525
  }
536
526
  // Log approval events
537
527
  approval_1.approvalManager.on('approved', (approval) => {
538
- console.log(chalk_1.default.green(`[Approval] Approved: ${approval.description}`));
528
+ console.log(chalk_1.default.green(`[Approval] Approved: ${approval.approvalId}`));
539
529
  });
540
530
  approval_1.approvalManager.on('rejected', (approval) => {
541
- console.log(chalk_1.default.red(`[Approval] Rejected: ${approval.description}`));
531
+ console.log(chalk_1.default.red(`[Approval] Rejected: ${approval.approvalId}`));
542
532
  });
543
533
  // Handle Claude start session request from mobile
544
534
  websocket_1.wsClient.on('claude_start_session', async (data) => {
545
- console.log(chalk_1.default.cyan(`[Claude] Start session request: ${data.directory}`));
535
+ console.log(chalk_1.default.cyan(`[Claude] Start session request for ${data.terminalSessionId}`));
546
536
  try {
547
537
  const result = await tools_1.claudeProcessManager.startSession(data.directory, data.terminalSessionId, data.dangerouslySkipPermissions, data.interactivePermissions);
548
538
  websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'active');
549
539
  websocket_1.wsClient.sendTerminalCwd({ terminalSessionId: data.terminalSessionId, cwd: result.cwd });
540
+ // Track session start for analytics
541
+ usageTracker.recordSessionStart();
550
542
  // Notify mobile that the session is ready for input
551
543
  websocket_1.wsClient.sendClaudeSessionEvent({
552
544
  sessionKey: data.terminalSessionId,
@@ -563,13 +555,13 @@ function createProgram() {
563
555
  // Claude is only spawned when the user actually sends a message (via user_message event)
564
556
  // This prevents duplicate transcript entries from double spawns
565
557
  websocket_1.wsClient.on('claude_resume_session', async (data) => {
566
- console.log(chalk_1.default.cyan(`[Claude] Resume session request: ${data.sessionKey} in ${data.directory}`));
558
+ console.log(chalk_1.default.cyan(`[Claude] Resume session request`));
567
559
  // Look up the correct directory from our locally scanned sessions
568
560
  // The mobile app may have a cached/stale directory (e.g. with corrupted hyphens)
569
561
  let resolvedDir = data.directory;
570
562
  const knownSession = tools_1.claudeSessionDetector.getSessions().find(s => s.sessionKey === data.sessionKey);
571
563
  if (knownSession && knownSession.directory) {
572
- console.log(chalk_1.default.dim(`[Claude] Using local directory for session: ${knownSession.directory}`));
564
+ console.log(chalk_1.default.dim(`[Claude] Using local directory for session`));
573
565
  resolvedDir = knownSession.directory;
574
566
  }
575
567
  if (resolvedDir === '~' || resolvedDir.startsWith('~/')) {
@@ -607,7 +599,7 @@ function createProgram() {
607
599
  sessionKey: data.sessionKey,
608
600
  event: { type: 'ready' },
609
601
  });
610
- console.log(chalk_1.default.green(`[Claude] Session ready (will spawn on first message): ${data.sessionKey}`));
602
+ console.log(chalk_1.default.green(`[Claude] Session ready (will spawn on first message)`));
611
603
  });
612
604
  // Handle directory listing requests
613
605
  websocket_1.wsClient.on('directory_list', async (data) => {
@@ -622,8 +614,10 @@ function createProgram() {
622
614
  const homeDir = os.homedir();
623
615
  // SECURITY: Only allow access to directories under home directory
624
616
  // This prevents accessing sensitive system files like /etc/passwd
625
- if (!resolvedPath.startsWith(homeDir)) {
626
- console.warn(chalk_1.default.yellow(`[Dir] Access denied - path outside home directory: ${resolvedPath}`));
617
+ // Uses path.relative() for cross-platform safety (handles Windows case-insensitivity)
618
+ const dirRelative = path.relative(homeDir, resolvedPath);
619
+ if (dirRelative.startsWith('..') || path.isAbsolute(dirRelative)) {
620
+ console.warn(chalk_1.default.yellow(`[Dir] Access denied — path outside home directory`));
627
621
  websocket_1.wsClient.sendDirectoryListResponse({ requestId: data.requestId, entries: [], currentPath: data.path });
628
622
  return;
629
623
  }
@@ -648,13 +642,13 @@ function createProgram() {
648
642
  });
649
643
  // Handle read file requests from mobile (e.g., CLAUDE.md)
650
644
  websocket_1.wsClient.on('read_file', async (data) => {
651
- console.log(chalk_1.default.dim(`[File] Read request: ${data.filePath}`));
645
+ console.log(chalk_1.default.dim(`[File] Read request received`));
652
646
  try {
653
647
  // SECURITY: Whitelist of allowed filenames
654
648
  const allowedFiles = ['CLAUDE.md', 'README.md', 'package.json'];
655
649
  const fileName = path.basename(data.filePath);
656
650
  if (!allowedFiles.includes(fileName)) {
657
- console.warn(chalk_1.default.yellow(`[File] Access denied - file not in whitelist: ${fileName}`));
651
+ console.warn(chalk_1.default.yellow(`[File] Access denied file not in whitelist`));
658
652
  websocket_1.wsClient.sendReadFileResponse({
659
653
  requestId: data.requestId,
660
654
  exists: false,
@@ -670,14 +664,16 @@ function createProgram() {
670
664
  }
671
665
  resolvedPath = path.resolve(resolvedPath);
672
666
  // SECURITY: Only allow access under home directory
667
+ // Uses path.relative() for cross-platform safety (handles Windows case-insensitivity)
673
668
  const homeDir = os.homedir();
674
- if (!resolvedPath.startsWith(homeDir)) {
675
- console.warn(chalk_1.default.yellow(`[File] Access denied - path outside home directory: ${resolvedPath}`));
669
+ const fileRelative = path.relative(homeDir, resolvedPath);
670
+ if (fileRelative.startsWith('..') || path.isAbsolute(fileRelative)) {
671
+ console.warn(chalk_1.default.yellow(`[File] Access denied — path outside home directory`));
676
672
  websocket_1.wsClient.sendReadFileResponse({
677
673
  requestId: data.requestId,
678
674
  exists: false,
679
675
  fileName,
680
- error: 'Path outside home directory',
676
+ error: 'Access denied',
681
677
  });
682
678
  return;
683
679
  }
@@ -710,20 +706,28 @@ function createProgram() {
710
706
  });
711
707
  }
712
708
  catch (error) {
713
- console.error(chalk_1.default.red(`[File] Error: ${error.message}`));
709
+ console.error(`[File] Error reading ${path.basename(data.filePath)}:`, error.message);
714
710
  websocket_1.wsClient.sendReadFileResponse({
715
711
  requestId: data.requestId,
716
712
  exists: false,
717
713
  fileName: path.basename(data.filePath),
718
- error: error.message,
714
+ error: 'File read failed',
719
715
  });
720
716
  }
721
717
  });
722
718
  // Handle transcript fetch requests from mobile
723
719
  websocket_1.wsClient.on('transcript_fetch', async (data) => {
724
- console.log(chalk_1.default.dim(`[Transcript] Fetching: ${data.sessionKey}, offset: ${data.offset}, limit: ${data.limit}, reverse: ${data.reverse}`));
720
+ console.log(chalk_1.default.dim(`[Transcript] Fetching: offset: ${data.offset}, limit: ${data.limit}`));
725
721
  try {
726
- 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)
722
+ // SECURITY: Validate transcript path is under ~/.claude/projects/ to prevent path traversal
723
+ const resolvedTranscriptPath = path.resolve(data.transcriptPath);
724
+ const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
725
+ const relPath = path.relative(claudeProjectsDir, resolvedTranscriptPath);
726
+ if (relPath.startsWith('..') || path.isAbsolute(relPath)) {
727
+ console.warn(chalk_1.default.yellow(`[Transcript] Access denied — path outside ~/.claude/projects/`));
728
+ return;
729
+ }
730
+ const result = await transcript_streamer_1.transcriptStreamer.fetchHistory(resolvedTranscriptPath, data.offset || 0, data.limit || 100, data.reverse !== false // Default to true (most recent first)
727
731
  );
728
732
  const payload = JSON.stringify(result.entries);
729
733
  console.log(chalk_1.default.dim(`[Transcript] Sending history: ${result.entries.length} entries, ${result.totalEntries} total, payload ~${(payload.length / 1024).toFixed(0)}KB`));
@@ -740,29 +744,22 @@ function createProgram() {
740
744
  });
741
745
  // Handle transcript subscribe
742
746
  websocket_1.wsClient.on('transcript_subscribe', (data) => {
743
- console.log(chalk_1.default.dim(`[Transcript] Subscribing: ${data.sessionKey}`));
744
- transcript_streamer_1.transcriptStreamer.subscribeToUpdates(data.sessionKey, data.transcriptPath);
747
+ console.log(chalk_1.default.dim(`[Transcript] Subscribing to session`));
748
+ // SECURITY: Validate transcript path to prevent path traversal
749
+ const resolvedSubPath = path.resolve(data.transcriptPath);
750
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects');
751
+ const subRelPath = path.relative(claudeDir, resolvedSubPath);
752
+ if (subRelPath.startsWith('..') || path.isAbsolute(subRelPath)) {
753
+ console.warn(chalk_1.default.yellow(`[Transcript] Subscribe denied — path outside ~/.claude/projects/`));
754
+ return;
755
+ }
756
+ transcript_streamer_1.transcriptStreamer.subscribeToUpdates(data.sessionKey, resolvedSubPath);
745
757
  });
746
758
  // Handle transcript unsubscribe
747
759
  websocket_1.wsClient.on('transcript_unsubscribe', (data) => {
748
- console.log(chalk_1.default.dim(`[Transcript] Unsubscribing: ${data.sessionKey}`));
760
+ console.log(chalk_1.default.dim(`[Transcript] Unsubscribing from session`));
749
761
  transcript_streamer_1.transcriptStreamer.unsubscribeFromUpdates(data.sessionKey);
750
762
  });
751
- // Handle SDK subscribe start - mobile wants live updates for a session
752
- // This is sent by API when mobile uses transcript_subscribe_sdk
753
- websocket_1.wsClient.on('transcript_subscribe_sdk_start', async (data) => {
754
- console.log(chalk_1.default.cyan(`[Transcript] SDK subscribe start: ${data.sessionKey}`));
755
- // Find the transcript file for this session
756
- const sessions = tools_1.claudeSessionDetector.scanSessions();
757
- const session = sessions.find(s => s.sessionKey === data.sessionKey);
758
- if (session?.transcriptPath) {
759
- console.log(chalk_1.default.dim(`[Transcript] Starting watch for: ${session.transcriptPath}`));
760
- transcript_streamer_1.transcriptStreamer.subscribeToUpdates(data.sessionKey, session.transcriptPath);
761
- }
762
- else {
763
- console.log(chalk_1.default.yellow(`[Transcript] No transcript found for session: ${data.sessionKey}`));
764
- }
765
- });
766
763
  // Handle claude sessions request - mobile app wants current sessions
767
764
  websocket_1.wsClient.on('claude_sessions_request', () => {
768
765
  console.log(chalk_1.default.cyan(`[Claude] Sessions requested by mobile`));
@@ -786,118 +783,6 @@ function createProgram() {
786
783
  websocket_1.wsClient.sendToolStatusUpdate('claude_code', hasActiveSession ? 'active' : 'inactive');
787
784
  }
788
785
  });
789
- // Handle RPC requests from the API gateway
790
- websocket_1.wsClient.on('rpc_request', async (data) => {
791
- console.log(chalk_1.default.cyan(`[RPC] Request: ${data.method}, requestId: ${data.requestId}`));
792
- try {
793
- if (data.method === 'get_session_history') {
794
- const { claudeSessionId, sessionKey, directory, limit = 400, offset = 0 } = data.params;
795
- console.log(chalk_1.default.dim(`[RPC] get_session_history request received`));
796
- // Find the transcript file
797
- let transcriptPath;
798
- // If claudeSessionId is provided, search for the JSONL file directly
799
- if (claudeSessionId) {
800
- // SECURITY: Validate claudeSessionId to prevent path traversal
801
- // Session IDs should be alphanumeric with hyphens/underscores only
802
- const sessionIdRegex = /^[a-zA-Z0-9_-]+$/;
803
- if (!sessionIdRegex.test(claudeSessionId)) {
804
- console.warn(chalk_1.default.yellow(`[RPC] Invalid claudeSessionId format - rejected`));
805
- websocket_1.wsClient.sendRpcResponse({
806
- requestId: data.requestId,
807
- error: { code: -32602, message: 'Invalid session ID format' }
808
- });
809
- return;
810
- }
811
- const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
812
- if (fs.existsSync(claudeProjectsDir)) {
813
- const projectDirs = fs.readdirSync(claudeProjectsDir);
814
- for (const projectDir of projectDirs) {
815
- // SECURITY: Validate projectDir as well to prevent traversal
816
- if (!sessionIdRegex.test(projectDir) && !/^[a-zA-Z0-9_.-]+$/.test(projectDir)) {
817
- continue;
818
- }
819
- const potentialPath = path.join(claudeProjectsDir, projectDir, `${claudeSessionId}.jsonl`);
820
- // SECURITY: Verify the resolved path is still under claudeProjectsDir
821
- const resolvedPotentialPath = path.resolve(potentialPath);
822
- if (!resolvedPotentialPath.startsWith(claudeProjectsDir)) {
823
- continue;
824
- }
825
- if (fs.existsSync(potentialPath)) {
826
- transcriptPath = potentialPath;
827
- console.log(chalk_1.default.dim(`[RPC] Found JSONL transcript`));
828
- break;
829
- }
830
- }
831
- }
832
- }
833
- // If not found by claudeSessionId, search all sessions
834
- if (!transcriptPath && tools_1.claudeSessionDetector.isClaudeInstalled()) {
835
- const sessions = tools_1.claudeSessionDetector.scanSessions();
836
- // First try to match by sessionKey
837
- let session = sessions.find(s => s.sessionKey === sessionKey);
838
- // If no match by sessionKey, try to find most recent session for the same directory
839
- if (!session && sessions.length > 0 && directory) {
840
- const normalizedDir = path.resolve(directory);
841
- session = sessions.find(s => path.resolve(s.directory) === normalizedDir);
842
- if (session) {
843
- console.log(chalk_1.default.dim(`[RPC] Using directory-matched session as fallback: ${session.sessionKey}`));
844
- }
845
- else {
846
- console.log(chalk_1.default.dim(`[RPC] No session found for directory: ${normalizedDir}`));
847
- }
848
- }
849
- if (session?.transcriptPath) {
850
- transcriptPath = session.transcriptPath;
851
- console.log(chalk_1.default.dim(`[RPC] Found transcript path: ${transcriptPath}`));
852
- }
853
- }
854
- if (!transcriptPath || !fs.existsSync(transcriptPath)) {
855
- console.log(chalk_1.default.yellow(`[RPC] No transcript file found for session`));
856
- websocket_1.wsClient.sendRpcResponse({
857
- requestId: data.requestId,
858
- result: { entries: [], totalEntries: 0, hasMore: false }
859
- });
860
- return;
861
- }
862
- // Read the transcript file
863
- const result = await transcript_streamer_1.transcriptStreamer.fetchHistory(transcriptPath, offset, limit, true);
864
- console.log(chalk_1.default.green(`[RPC] Loaded ${result.entries.length} entries from transcript`));
865
- // IMPORTANT: Start watching this transcript for live updates
866
- // This is needed because SDK mode doesn't send transcript_subscribe
867
- // Use sessionKey from mobile for updates (must match what mobile is listening for)
868
- const updateSessionKey = sessionKey;
869
- if (!updateSessionKey) {
870
- console.log(chalk_1.default.yellow(`[RPC] No sessionKey provided, using claudeSessionId - updates may not route correctly`));
871
- }
872
- const watchKey = updateSessionKey || claudeSessionId || data.requestId;
873
- console.log(chalk_1.default.cyan(`[RPC] Starting file watch for live updates: ${watchKey}`));
874
- transcript_streamer_1.transcriptStreamer.subscribeToUpdates(watchKey, transcriptPath);
875
- websocket_1.wsClient.sendRpcResponse({
876
- requestId: data.requestId,
877
- result: {
878
- entries: result.entries,
879
- totalEntries: result.totalEntries,
880
- hasMore: result.hasMore,
881
- sessionKey: watchKey, // Tell mobile which sessionKey to listen for updates
882
- }
883
- });
884
- }
885
- else {
886
- console.log(chalk_1.default.yellow(`[RPC] Unknown method: ${data.method}`));
887
- websocket_1.wsClient.sendRpcResponse({
888
- requestId: data.requestId,
889
- error: { code: -32601, message: `Method not found: ${data.method}` }
890
- });
891
- }
892
- }
893
- catch (error) {
894
- console.error(chalk_1.default.red(`[RPC] Error handling ${data.method}:`, error.message));
895
- websocket_1.wsClient.sendRpcResponse({
896
- requestId: data.requestId,
897
- error: { code: -32603, message: error.message || 'Internal error' }
898
- });
899
- }
900
- });
901
786
  // Forward live transcript updates to WebSocket
902
787
  transcript_streamer_1.transcriptStreamer.on('update', (data) => {
903
788
  console.log(chalk_1.default.green(`[Transcript] Sending update for ${data.sessionKey}: ${data.entry?.type}`));
@@ -927,7 +812,7 @@ function createProgram() {
927
812
  });
928
813
  // Forward tool activity events to mobile (non-blocking notifications)
929
814
  tools_1.claudeProcessManager.on('tool_activity', (data) => {
930
- console.log(chalk_1.default.dim(`[Claude] Tool activity: ${data.toolName} - ${data.inputSummary?.substring(0, 60)}`));
815
+ console.log(chalk_1.default.dim(`[Claude] Tool activity: ${data.toolName}`));
931
816
  websocket_1.wsClient.sendToolActivity(data);
932
817
  });
933
818
  // Forward permission prompts from hook system to mobile (interactive approval)
@@ -943,7 +828,7 @@ function createProgram() {
943
828
  });
944
829
  // Handle permission responses from mobile → route back to hook IPC
945
830
  websocket_1.wsClient.on('permission_response', (data) => {
946
- console.log(chalk_1.default.green(`[Claude] Permission response: ${data.promptId} -> ${data.decision}`));
831
+ console.log(chalk_1.default.green(`[Claude] Permission response: ${data.promptId}`));
947
832
  tools_1.claudeProcessManager.handlePermissionResponse(data.promptId, data.decision, data.reason);
948
833
  });
949
834
  // Handle permission rules sync from mobile — write rules to disk for hook script
@@ -953,16 +838,9 @@ function createProgram() {
953
838
  tools_1.claudeProcessManager.updatePermissionRules(data.rules);
954
839
  }
955
840
  });
956
- // Handle mobile disconnect — full reset: clear taken-over, auto-allow pending, tear down hooks
957
- websocket_1.wsClient.on('mobile_disconnected', () => {
958
- console.log(chalk_1.default.yellow(`[Claude] Mobile disconnected — full permission reset`));
959
- tools_1.claudeProcessManager.clearAllTakenOver();
960
- tools_1.claudeProcessManager.autoAllowAllPendingPrompts();
961
- tools_1.claudeProcessManager.cleanupAllPermissionState();
962
- });
963
841
  // Handle Claude approval responses from mobile
964
842
  websocket_1.wsClient.on('claude_approval_response', (data) => {
965
- console.log(chalk_1.default.green(`[Claude] Approval response: ${data.approvalId} -> ${data.response}`));
843
+ console.log(chalk_1.default.green(`[Claude] Approval response: ${data.approvalId}`));
966
844
  tools_1.claudeProcessManager.handleApprovalResponse(data.approvalId, data.response);
967
845
  });
968
846
  // Handle user messages from mobile app (send to Claude session)
@@ -980,7 +858,7 @@ function createProgram() {
980
858
  if (!tools_1.claudeProcessManager.isTakenOver(terminalSessionId)) {
981
859
  // Fresh session from auto-prompt (quick action): start new session with directory
982
860
  if (data.directory) {
983
- console.log(chalk_1.default.cyan(`[Claude] Starting fresh session for auto-prompt in ${data.directory}`));
861
+ console.log(chalk_1.default.cyan(`[Claude] Starting fresh session for auto-prompt`));
984
862
  const sent = await tools_1.claudeProcessManager.startAndSendMessage(data.directory, terminalSessionId, data.message, data.mode?.permissionMode === 'bypassPermissions', data.interactivePermissions);
985
863
  if (sent) {
986
864
  // Notify mobile that session is ready
@@ -1034,7 +912,7 @@ function createProgram() {
1034
912
  // Forward thinking content to mobile
1035
913
  tools_1.claudeProcessManager.on('thinking_content', (data) => {
1036
914
  if (data.content || !data.partial) {
1037
- console.log(chalk_1.default.magenta(`[Claude] Thinking${data.partial ? ' (streaming)' : ' (complete)'}: ${data.content?.substring(0, 50) || '...'}`));
915
+ console.log(chalk_1.default.magenta(`[Claude] Thinking${data.partial ? ' (streaming)' : ' (complete)'}: ${data.content?.length || 0} chars`));
1038
916
  }
1039
917
  websocket_1.wsClient.sendThinkingContent({
1040
918
  sessionKey: data.sessionKey,
@@ -1043,26 +921,35 @@ function createProgram() {
1043
921
  partial: data.partial,
1044
922
  });
1045
923
  });
1046
- // Forward token usage to mobile
924
+ // Persistent usage tracker
925
+ const usageTracker = new usage_tracker_1.UsageTracker();
926
+ websocket_1.wsClient.setUsageTracker(usageTracker);
927
+ // Forward token usage to mobile and persist locally
1047
928
  tools_1.claudeProcessManager.on('token_usage', (data) => {
1048
929
  console.log(chalk_1.default.blue(`[Claude] Tokens: ${data.usage.inputTokens} in / ${data.usage.outputTokens} out`));
930
+ usageTracker.recordUsage(data.usage.inputTokens || 0, data.usage.outputTokens || 0);
1049
931
  websocket_1.wsClient.sendTokenUsage({
1050
932
  sessionKey: data.sessionKey,
1051
933
  usage: data.usage,
1052
934
  });
1053
935
  });
936
+ // Handle usage stats request from mobile (pull-refresh)
937
+ websocket_1.wsClient.on('usage_stats_request', () => {
938
+ console.log(chalk_1.default.blue('[Analytics] Usage stats requested by mobile'));
939
+ websocket_1.wsClient.sendAllUsageStats();
940
+ });
1054
941
  // When a fresh session captures a real session_id, update the transcript
1055
942
  // file watcher so the mobile gets real-time updates from the correct JSONL file.
1056
943
  // This is critical for brainstorm/quick-action sessions where startAndSendMessage
1057
944
  // creates a new Claude session with a new transcript file.
1058
945
  tools_1.claudeProcessManager.on('session_id_captured', (data) => {
1059
946
  const { terminalSessionId, sessionId } = data;
1060
- console.log(chalk_1.default.green(`[Claude] New session_id captured for ${terminalSessionId}: ${sessionId}`));
947
+ console.log(chalk_1.default.green(`[Claude] New session_id captured for ${terminalSessionId}`));
1061
948
  // Re-scan to pick up the new session's transcript path
1062
949
  const sessions = tools_1.claudeSessionDetector.scanSessions();
1063
950
  const newSession = sessions.find((s) => s.sessionKey === sessionId);
1064
951
  if (newSession?.transcriptPath) {
1065
- console.log(chalk_1.default.green(`[Claude] Updating transcript watcher → ${newSession.transcriptPath}`));
952
+ console.log(chalk_1.default.green(`[Claude] Updating transcript watcher`));
1066
953
  transcript_streamer_1.transcriptStreamer.subscribeToUpdates(terminalSessionId, newSession.transcriptPath);
1067
954
  }
1068
955
  else {
@@ -1071,11 +958,11 @@ function createProgram() {
1071
958
  const retrySessions = tools_1.claudeSessionDetector.scanSessions();
1072
959
  const retrySession = retrySessions.find((s) => s.sessionKey === sessionId);
1073
960
  if (retrySession?.transcriptPath) {
1074
- console.log(chalk_1.default.green(`[Claude] Retry: updating transcript watcher → ${retrySession.transcriptPath}`));
961
+ console.log(chalk_1.default.green(`[Claude] Retry: updating transcript watcher`));
1075
962
  transcript_streamer_1.transcriptStreamer.subscribeToUpdates(terminalSessionId, retrySession.transcriptPath);
1076
963
  }
1077
964
  else {
1078
- console.log(chalk_1.default.yellow(`[Claude] Retry: still not found for ${sessionId}`));
965
+ console.log(chalk_1.default.yellow(`[Claude] Retry: transcript still not found`));
1079
966
  }
1080
967
  }, 1000);
1081
968
  }
@@ -1086,7 +973,7 @@ function createProgram() {
1086
973
  console.log(chalk_1.default.cyan(`[Claude] Task list: ${data.tasks?.length || 0} tasks`));
1087
974
  }
1088
975
  else {
1089
- console.log(chalk_1.default.cyan(`[Claude] Task ${data.type}: ${data.task?.subject || data.task?.id}`));
976
+ console.log(chalk_1.default.cyan(`[Claude] Task ${data.type}: ${data.task?.id || 'unknown'}`));
1090
977
  }
1091
978
  websocket_1.wsClient.sendTaskProgress({
1092
979
  sessionKey: data.sessionKey,
@@ -1096,10 +983,8 @@ function createProgram() {
1096
983
  });
1097
984
  });
1098
985
  websocket_1.wsClient.on('disconnected', (reason) => {
1099
- console.log(chalk_1.default.yellow(`\nDisconnected: ${reason}`));
1100
- if (reason !== 'io client disconnect') {
1101
- console.log(chalk_1.default.dim('Attempting to reconnect...'));
1102
- }
986
+ console.log(chalk_1.default.yellow(`\nMobile disconnected: ${reason}`));
987
+ console.log(chalk_1.default.dim('Waiting for mobile to reconnect...'));
1103
988
  });
1104
989
  websocket_1.wsClient.on('error', (error) => {
1105
990
  console.error(chalk_1.default.red(`Connection error: ${error.message}`));
@@ -1107,9 +992,17 @@ function createProgram() {
1107
992
  // Keep the process running
1108
993
  process.on('SIGINT', () => {
1109
994
  console.log(chalk_1.default.yellow('\nDisconnecting...'));
995
+ tools_1.claudeProcessManager.cleanupAllPermissionState();
996
+ usageTracker.flush();
1110
997
  tools_1.claudeSessionDetector.stopWatching();
1111
998
  transcript_streamer_1.transcriptStreamer.cleanup();
1112
999
  websocket_1.wsClient.disconnect();
1000
+ const logPath = (0, logger_1.getLogFilePath)();
1001
+ (0, logger_1.closeDebugLog)();
1002
+ if (logPath) {
1003
+ // Use original console since we just closed the debug log
1004
+ process.stdout.write(`\nDebug log saved: ${logPath}\n`);
1005
+ }
1113
1006
  process.exit(0);
1114
1007
  });
1115
1008
  // Keep alive
@@ -1120,34 +1013,64 @@ function createProgram() {
1120
1013
  console.error(chalk_1.default.red(error.message || 'Unknown error'));
1121
1014
  }
1122
1015
  }
1123
- // Helper function to wait for pairing
1124
- async function waitForPairing(deviceId) {
1125
- return new Promise((resolve, reject) => {
1126
- const checkInterval = setInterval(async () => {
1127
- try {
1128
- const status = await api_1.api.checkPairingStatus(deviceId);
1129
- if (status.isPaired) {
1130
- clearInterval(checkInterval);
1131
- config_1.config.userId = status.userId;
1132
- config_1.config.pairedAt = new Date().toISOString();
1133
- console.log(chalk_1.default.green('\n✓ Device paired successfully!'));
1134
- console.log(chalk_1.default.dim('\nConnecting to receive commands...\n'));
1135
- // Auto-connect after successful pairing
1136
- resolve();
1137
- }
1138
- }
1139
- catch (error) {
1140
- // Continue waiting
1141
- }
1142
- }, 2000);
1016
+ // Helper function to wait for pairing via WebSocket event
1017
+ async function waitForPairing() {
1018
+ return new Promise((resolve) => {
1019
+ websocket_1.wsClient.on('pair_device', (data) => {
1020
+ resolve({ mobileDeviceId: data.mobileDeviceId });
1021
+ });
1143
1022
  // Handle Ctrl+C
1144
1023
  process.on('SIGINT', () => {
1145
- clearInterval(checkInterval);
1146
1024
  console.log(chalk_1.default.yellow('\nPairing cancelled.'));
1025
+ websocket_1.wsClient.disconnect();
1147
1026
  process.exit(0);
1148
1027
  });
1149
1028
  });
1150
1029
  }
1030
+ // Logs command — list and manage debug log files
1031
+ program
1032
+ .command('logs')
1033
+ .description('List debug log files for troubleshooting')
1034
+ .option('--clean', 'Delete all debug log files')
1035
+ .option('--latest', 'Print path to the most recent log file')
1036
+ .action((options) => {
1037
+ const logDir = path.join(os.homedir(), '.forkoff-cli', 'logs');
1038
+ if (!fs.existsSync(logDir)) {
1039
+ console.log(chalk_1.default.dim('No debug logs found. Run with --debug to generate logs.'));
1040
+ return;
1041
+ }
1042
+ const logFiles = fs.readdirSync(logDir)
1043
+ .filter(f => f.startsWith('debug-') && f.endsWith('.log'))
1044
+ .sort()
1045
+ .reverse();
1046
+ if (logFiles.length === 0) {
1047
+ console.log(chalk_1.default.dim('No debug logs found. Run with --debug to generate logs.'));
1048
+ return;
1049
+ }
1050
+ if (options.clean) {
1051
+ for (const file of logFiles) {
1052
+ try {
1053
+ fs.unlinkSync(path.join(logDir, file));
1054
+ }
1055
+ catch { }
1056
+ }
1057
+ console.log(chalk_1.default.green(`Deleted ${logFiles.length} log file(s).`));
1058
+ return;
1059
+ }
1060
+ if (options.latest) {
1061
+ console.log(path.join(logDir, logFiles[0]));
1062
+ return;
1063
+ }
1064
+ console.log(chalk_1.default.bold('Debug log files:\n'));
1065
+ for (const file of logFiles) {
1066
+ const filePath = path.join(logDir, file);
1067
+ const stat = fs.statSync(filePath);
1068
+ const sizeKB = (stat.size / 1024).toFixed(1);
1069
+ console.log(` ${file} ${chalk_1.default.dim(`(${sizeKB} KB)`)}`);
1070
+ }
1071
+ console.log(chalk_1.default.dim(`\nLog directory: ${logDir}`));
1072
+ console.log(chalk_1.default.dim('Run with --debug to generate a new log file.'));
1073
+ });
1151
1074
  // Help command
1152
1075
  program
1153
1076
  .command('help')