forkoff 1.0.11 → 1.0.13

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 (57) hide show
  1. package/README.md +7 -4
  2. package/dist/__tests__/cli-commands.test.d.ts +6 -0
  3. package/dist/__tests__/cli-commands.test.d.ts.map +1 -0
  4. package/dist/__tests__/cli-commands.test.js +213 -0
  5. package/dist/__tests__/cli-commands.test.js.map +1 -0
  6. package/dist/__tests__/startup.test.d.ts +11 -0
  7. package/dist/__tests__/startup.test.d.ts.map +1 -0
  8. package/dist/__tests__/startup.test.js +234 -0
  9. package/dist/__tests__/startup.test.js.map +1 -0
  10. package/dist/__tests__/tools/claude-process.test.js +221 -15
  11. package/dist/__tests__/tools/claude-process.test.js.map +1 -1
  12. package/dist/__tests__/tools/permission-hook.test.d.ts +17 -0
  13. package/dist/__tests__/tools/permission-hook.test.d.ts.map +1 -0
  14. package/dist/__tests__/tools/permission-hook.test.js +616 -0
  15. package/dist/__tests__/tools/permission-hook.test.js.map +1 -0
  16. package/dist/__tests__/tools/permission-ipc.test.d.ts +11 -0
  17. package/dist/__tests__/tools/permission-ipc.test.d.ts.map +1 -0
  18. package/dist/__tests__/tools/permission-ipc.test.js +612 -0
  19. package/dist/__tests__/tools/permission-ipc.test.js.map +1 -0
  20. package/dist/config.js +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1010 -898
  24. package/dist/index.js.map +1 -1
  25. package/dist/startup.d.ts.map +1 -1
  26. package/dist/startup.js +45 -15
  27. package/dist/startup.js.map +1 -1
  28. package/dist/tools/__tests__/claude-sessions.test.d.ts +2 -0
  29. package/dist/tools/__tests__/claude-sessions.test.d.ts.map +1 -0
  30. package/dist/tools/__tests__/claude-sessions.test.js +306 -0
  31. package/dist/tools/__tests__/claude-sessions.test.js.map +1 -0
  32. package/dist/tools/claude-process.d.ts +81 -4
  33. package/dist/tools/claude-process.d.ts.map +1 -1
  34. package/dist/tools/claude-process.js +332 -20
  35. package/dist/tools/claude-process.js.map +1 -1
  36. package/dist/tools/claude-sessions.d.ts +5 -0
  37. package/dist/tools/claude-sessions.d.ts.map +1 -1
  38. package/dist/tools/claude-sessions.js +16 -2
  39. package/dist/tools/claude-sessions.js.map +1 -1
  40. package/dist/tools/index.d.ts +1 -0
  41. package/dist/tools/index.d.ts.map +1 -1
  42. package/dist/tools/index.js +3 -1
  43. package/dist/tools/index.js.map +1 -1
  44. package/dist/tools/permission-hook.d.ts +41 -0
  45. package/dist/tools/permission-hook.d.ts.map +1 -0
  46. package/dist/tools/permission-hook.js +312 -0
  47. package/dist/tools/permission-hook.js.map +1 -0
  48. package/dist/tools/permission-ipc.d.ts +109 -0
  49. package/dist/tools/permission-ipc.d.ts.map +1 -0
  50. package/dist/tools/permission-ipc.js +295 -0
  51. package/dist/tools/permission-ipc.js.map +1 -0
  52. package/dist/websocket.d.ts +14 -0
  53. package/dist/websocket.d.ts.map +1 -1
  54. package/dist/websocket.js +34 -4
  55. package/dist/websocket.js.map +1 -1
  56. package/jest.config.js +3 -0
  57. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
38
38
  };
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.createProgram = createProgram;
40
41
  const commander_1 = require("commander");
41
42
  const chalk_1 = __importDefault(require("chalk"));
42
43
  const qrcode_terminal_1 = __importDefault(require("qrcode-terminal"));
@@ -52,1006 +53,1117 @@ const startup_1 = require("./startup");
52
53
  const fs = __importStar(require("fs"));
53
54
  const path = __importStar(require("path"));
54
55
  const os = __importStar(require("os"));
55
- const program = new commander_1.Command();
56
- program
57
- .name('forkoff')
58
- .description('CLI tool for ForkOff - Connect your AI coding tools to mobile')
59
- .version('1.0.0')
60
- .option('-q, --quiet', 'Suppress all output (for background operation)');
61
- program.hook('preAction', () => {
62
- if (program.opts().quiet) {
63
- (0, logger_1.setQuiet)(true);
64
- }
65
- });
66
- // Configure API/WS URLs
67
- program
68
- .command('config')
69
- .description('Configure ForkOff CLI settings')
70
- .option('-a, --api <url>', 'Set API URL')
71
- .option('-w, --ws <url>', 'Set WebSocket URL')
72
- .option('-n, --name <name>', 'Set device name')
73
- .option('--show', 'Show current configuration')
74
- .option('--reset', 'Reset all configuration')
75
- .action(async (options) => {
76
- if (options.reset) {
77
- config_1.config.reset();
78
- console.log(chalk_1.default.green('Configuration reset successfully'));
79
- return;
80
- }
81
- if (options.api) {
82
- config_1.config.apiUrl = options.api;
83
- console.log(chalk_1.default.green(`API URL set to: ${options.api}`));
84
- }
85
- if (options.ws) {
86
- config_1.config.wsUrl = options.ws;
87
- console.log(chalk_1.default.green(`WebSocket URL set to: ${options.ws}`));
88
- }
89
- if (options.name) {
90
- config_1.config.deviceName = options.name;
91
- console.log(chalk_1.default.green(`Device name set to: ${options.name}`));
92
- }
93
- if (options.show || (!options.api && !options.ws && !options.name && !options.reset)) {
94
- console.log(chalk_1.default.bold('\nCurrent Configuration:'));
95
- console.log(` API URL: ${chalk_1.default.cyan(config_1.config.apiUrl)}`);
96
- console.log(` WebSocket: ${chalk_1.default.cyan(config_1.config.wsUrl)}`);
97
- console.log(` Device Name: ${chalk_1.default.cyan(config_1.config.deviceName)}`);
98
- console.log(` Device ID: ${chalk_1.default.cyan(config_1.config.deviceId || 'Not registered')}`);
99
- console.log(` Paired: ${config_1.config.isPaired ? chalk_1.default.green('Yes') : chalk_1.default.yellow('No')}`);
100
- console.log(` Config Path: ${chalk_1.default.dim(config_1.config.getPath())}`);
101
- const startupStatus = config_1.config.startupEnabled === null
102
- ? chalk_1.default.dim('Not configured')
103
- : config_1.config.startupEnabled ? chalk_1.default.green('Enabled') : chalk_1.default.yellow('Disabled');
104
- console.log(` Startup: ${startupStatus}`);
105
- }
106
- });
107
- // Pair device with mobile app
108
- program
109
- .command('pair')
110
- .description('Generate pairing code to connect with mobile app')
111
- .action(async () => {
112
- const spinner = (0, logger_1.createSpinner)('Connecting to ForkOff server...').start();
113
- try {
114
- // Check server health
115
- const isHealthy = await api_1.api.healthCheck();
116
- if (!isHealthy) {
117
- spinner.fail('Cannot connect to ForkOff server');
118
- console.log(chalk_1.default.yellow(`\nMake sure the server is running at ${config_1.config.apiUrl}`));
119
- console.log(chalk_1.default.dim('Use "forkoff config --api <url>" to change the server URL'));
56
+ function createProgram() {
57
+ const program = new commander_1.Command();
58
+ program
59
+ .name('forkoff')
60
+ .description('CLI tool for ForkOff - Connect your AI coding tools to mobile')
61
+ .version(require('../package.json').version)
62
+ .option('-q, --quiet', 'Suppress all output (for background operation)');
63
+ program.hook('preAction', () => {
64
+ if (program.opts().quiet) {
65
+ (0, logger_1.setQuiet)(true);
66
+ }
67
+ });
68
+ // Configure API/WS URLs
69
+ program
70
+ .command('config')
71
+ .description('Configure ForkOff CLI settings')
72
+ .option('-a, --api <url>', 'Set API URL')
73
+ .option('-w, --ws <url>', 'Set WebSocket URL')
74
+ .option('-n, --name <name>', 'Set device name')
75
+ .option('--show', 'Show current configuration')
76
+ .option('--reset', 'Reset all configuration')
77
+ .action(async (options) => {
78
+ if (options.reset) {
79
+ config_1.config.reset();
80
+ console.log(chalk_1.default.green('Configuration reset successfully'));
120
81
  return;
121
82
  }
122
- spinner.text = 'Registering device...';
123
- // Register device or refresh pairing code
124
- let result;
125
- if (config_1.config.deviceId) {
126
- try {
127
- result = await api_1.api.refreshPairingCode(config_1.config.deviceId);
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}`));
90
+ }
91
+ if (options.name) {
92
+ config_1.config.deviceName = options.name;
93
+ console.log(chalk_1.default.green(`Device name set to: ${options.name}`));
94
+ }
95
+ if (options.show || (!options.api && !options.ws && !options.name && !options.reset)) {
96
+ 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)}`);
99
+ console.log(` Device Name: ${chalk_1.default.cyan(config_1.config.deviceName)}`);
100
+ console.log(` Device ID: ${chalk_1.default.cyan(config_1.config.deviceId || 'Not registered')}`);
101
+ console.log(` Paired: ${config_1.config.isPaired ? chalk_1.default.green('Yes') : chalk_1.default.yellow('No')}`);
102
+ console.log(` Config Path: ${chalk_1.default.dim(config_1.config.getPath())}`);
103
+ const startupStatus = config_1.config.startupEnabled === null
104
+ ? chalk_1.default.dim('Not configured')
105
+ : config_1.config.startupEnabled ? chalk_1.default.green('Enabled') : chalk_1.default.yellow('Disabled');
106
+ console.log(` Startup: ${startupStatus}`);
107
+ }
108
+ });
109
+ // Pair device with mobile app
110
+ program
111
+ .command('pair')
112
+ .description('Generate pairing code to connect with mobile app')
113
+ .action(async () => {
114
+ const spinner = (0, logger_1.createSpinner)('Connecting to ForkOff server...').start();
115
+ 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;
128
123
  }
129
- catch {
130
- // Device might not exist anymore, register fresh
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 {
131
137
  result = await api_1.api.registerDevice();
132
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');
143
+ // Display pairing info
144
+ 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}`;
147
+ qrcode_terminal_1.default.generate(pairingUrl, { small: true }, (code) => {
148
+ console.log(code);
149
+ });
150
+ console.log(chalk_1.default.bold('\nOr enter this code manually:\n'));
151
+ console.log(chalk_1.default.bgBlue.white.bold(` ${result.pairingCode} `));
152
+ console.log();
153
+ const expiresAt = new Date(result.expiresAt);
154
+ console.log(chalk_1.default.dim(`Code expires at: ${expiresAt.toLocaleTimeString()}`));
155
+ console.log();
156
+ // Wait for pairing
157
+ console.log(chalk_1.default.yellow('Waiting for mobile app to scan...'));
158
+ console.log(chalk_1.default.dim('Press Ctrl+C to cancel\n'));
159
+ await waitForPairing(result.device.id);
160
+ // Auto-register startup if not explicitly disabled
161
+ if (config_1.config.startupEnabled !== false) {
162
+ try {
163
+ await (0, startup_1.enableStartup)();
164
+ console.log(chalk_1.default.green('Automatic startup registered. Use "forkoff startup --disable" to opt out.'));
165
+ }
166
+ catch {
167
+ // Non-critical — don't fail pairing over this
168
+ }
169
+ }
170
+ // Auto-connect after successful pairing
171
+ await startConnection();
133
172
  }
134
- else {
135
- result = await api_1.api.registerDevice();
173
+ catch (error) {
174
+ spinner.fail('Failed to register device');
175
+ console.error(chalk_1.default.red(error.message || 'Unknown error'));
136
176
  }
137
- // Save device info
138
- config_1.config.deviceId = result.device.id;
139
- config_1.config.pairingCode = result.pairingCode;
140
- spinner.succeed('Device registered successfully!\n');
141
- // Display pairing info
142
- console.log(chalk_1.default.bold('Scan this QR code with the ForkOff mobile app:\n'));
143
- // Generate QR code with pairing URL
144
- const pairingUrl = `forkoff://pair/${result.pairingCode}`;
145
- qrcode_terminal_1.default.generate(pairingUrl, { small: true }, (code) => {
146
- console.log(code);
147
- });
148
- console.log(chalk_1.default.bold('\nOr enter this code manually:\n'));
149
- console.log(chalk_1.default.bgBlue.white.bold(` ${result.pairingCode} `));
150
- console.log();
151
- const expiresAt = new Date(result.expiresAt);
152
- console.log(chalk_1.default.dim(`Code expires at: ${expiresAt.toLocaleTimeString()}`));
153
- console.log();
154
- // Wait for pairing
155
- console.log(chalk_1.default.yellow('Waiting for mobile app to scan...'));
156
- console.log(chalk_1.default.dim('Press Ctrl+C to cancel\n'));
157
- await waitForPairing(result.device.id);
158
- // Auto-register startup if not explicitly disabled
159
- if (config_1.config.startupEnabled !== false) {
177
+ });
178
+ // Check device status
179
+ program
180
+ .command('status')
181
+ .description('Check device connection status')
182
+ .action(async () => {
183
+ if (!config_1.config.deviceId) {
184
+ console.log(chalk_1.default.yellow('Device not registered. Run "forkoff pair" first.'));
185
+ return;
186
+ }
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'));
210
+ }
211
+ });
212
+ // Connect and stay online (for returning users who already paired)
213
+ program
214
+ .command('connect')
215
+ .description('Reconnect to ForkOff (for previously paired devices)')
216
+ .action(async () => {
217
+ if (!config_1.config.deviceId) {
218
+ console.log(chalk_1.default.yellow('Device not registered. Run "forkoff pair" first.'));
219
+ return;
220
+ }
221
+ if (!config_1.config.isPaired) {
222
+ console.log(chalk_1.default.yellow('Device not paired. Run "forkoff pair" and scan the QR code.'));
223
+ return;
224
+ }
225
+ // Auto-register startup if not explicitly disabled and not already registered
226
+ if (config_1.config.startupEnabled !== false && !(0, startup_1.isStartupRegistered)()) {
160
227
  try {
161
228
  await (0, startup_1.enableStartup)();
162
229
  console.log(chalk_1.default.green('Automatic startup registered. Use "forkoff startup --disable" to opt out.'));
163
230
  }
164
231
  catch {
165
- // Non-critical — don't fail pairing over this
232
+ // Non-critical
166
233
  }
167
234
  }
168
- // Auto-connect after successful pairing
169
235
  await startConnection();
170
- }
171
- catch (error) {
172
- spinner.fail('Failed to register device');
173
- console.error(chalk_1.default.red(error.message || 'Unknown error'));
174
- }
175
- });
176
- // Check device status
177
- program
178
- .command('status')
179
- .description('Check device connection status')
180
- .action(async () => {
181
- if (!config_1.config.deviceId) {
182
- console.log(chalk_1.default.yellow('Device not registered. Run "forkoff pair" first.'));
183
- return;
184
- }
185
- const spinner = (0, logger_1.createSpinner)('Checking status...').start();
186
- try {
187
- const status = await api_1.api.checkPairingStatus(config_1.config.deviceId);
188
- spinner.stop();
189
- console.log(chalk_1.default.bold('\nDevice Status:'));
190
- console.log(` Device ID: ${chalk_1.default.cyan(config_1.config.deviceId)}`);
191
- console.log(` Device Name: ${chalk_1.default.cyan(config_1.config.deviceName)}`);
192
- console.log(` Paired: ${status.isPaired ? chalk_1.default.green('Yes') : chalk_1.default.yellow('No')}`);
193
- if (status.isPaired) {
194
- config_1.config.userId = status.userId;
195
- config_1.config.pairedAt = config_1.config.pairedAt || new Date().toISOString();
196
- console.log(` User ID: ${chalk_1.default.cyan(status.userId)}`);
236
+ });
237
+ // Disconnect/unpair device
238
+ program
239
+ .command('disconnect')
240
+ .description('Disconnect and unpair device')
241
+ .action(async () => {
242
+ websocket_1.wsClient.disconnect();
243
+ // Disable startup registration so it doesn't run on boot after unpair
244
+ if ((0, startup_1.isStartupRegistered)()) {
245
+ try {
246
+ await (0, startup_1.disableStartup)();
247
+ }
248
+ catch { }
197
249
  }
198
- if (websocket_1.wsClient.isConnected) {
199
- console.log(` WebSocket: ${chalk_1.default.green('Connected')}`);
250
+ config_1.config.userId = null;
251
+ config_1.config.pairedAt = null;
252
+ config_1.config.pairingCode = null;
253
+ console.log(chalk_1.default.green('Device disconnected and unpaired.'));
254
+ console.log(chalk_1.default.dim('Run "forkoff pair" to pair again.'));
255
+ });
256
+ // Manage startup registration
257
+ program
258
+ .command('startup')
259
+ .description('Manage automatic startup on login')
260
+ .option('--enable', 'Enable automatic startup')
261
+ .option('--disable', 'Disable automatic startup')
262
+ .option('--status', 'Show startup status (default)')
263
+ .action(async (options) => {
264
+ if (options.enable) {
265
+ try {
266
+ await (0, startup_1.enableStartup)();
267
+ console.log(chalk_1.default.green('Automatic startup enabled.'));
268
+ console.log(chalk_1.default.dim(`Binary: ${(0, startup_1.getBinaryPath)()}`));
269
+ console.log(chalk_1.default.dim('ForkOff will connect automatically when you log in.'));
270
+ }
271
+ catch (error) {
272
+ console.error(chalk_1.default.red(`Failed to enable startup: ${error.message}`));
273
+ }
274
+ return;
200
275
  }
201
- else {
202
- console.log(` WebSocket: ${chalk_1.default.yellow('Disconnected')}`);
276
+ if (options.disable) {
277
+ try {
278
+ await (0, startup_1.disableStartup)();
279
+ console.log(chalk_1.default.green('Automatic startup disabled.'));
280
+ console.log(chalk_1.default.dim('ForkOff will no longer start on login.'));
281
+ }
282
+ catch (error) {
283
+ console.error(chalk_1.default.red(`Failed to disable startup: ${error.message}`));
284
+ }
285
+ return;
203
286
  }
204
- }
205
- catch (error) {
206
- spinner.fail('Failed to check status');
207
- console.error(chalk_1.default.red(error.message || 'Unknown error'));
208
- }
209
- });
210
- // Connect and stay online (for returning users who already paired)
211
- program
212
- .command('connect')
213
- .description('Reconnect to ForkOff (for previously paired devices)')
214
- .action(async () => {
215
- if (!config_1.config.deviceId) {
216
- console.log(chalk_1.default.yellow('Device not registered. Run "forkoff pair" first.'));
217
- return;
218
- }
219
- if (!config_1.config.isPaired) {
220
- console.log(chalk_1.default.yellow('Device not paired. Run "forkoff pair" and scan the QR code.'));
221
- return;
222
- }
223
- // Auto-register startup if not explicitly disabled and not already registered
224
- if (config_1.config.startupEnabled !== false && !(0, startup_1.isStartupRegistered)()) {
287
+ // Default: show status
288
+ const registered = (0, startup_1.isStartupRegistered)();
289
+ const configState = config_1.config.startupEnabled;
290
+ console.log(chalk_1.default.bold('\nStartup Status:'));
291
+ console.log(` OS Registration: ${registered ? chalk_1.default.green('Registered') : chalk_1.default.yellow('Not registered')}`);
292
+ console.log(` Config State: ${configState === null ? chalk_1.default.dim('Not configured') :
293
+ configState ? chalk_1.default.green('Enabled') : chalk_1.default.yellow('Disabled')}`);
225
294
  try {
226
- await (0, startup_1.enableStartup)();
227
- console.log(chalk_1.default.green('Automatic startup registered. Use "forkoff startup --disable" to opt out.'));
295
+ console.log(` Binary Path: ${chalk_1.default.dim((0, startup_1.getBinaryPath)())}`);
228
296
  }
229
297
  catch {
230
- // Non-critical
231
- }
232
- }
233
- await startConnection();
234
- });
235
- // Disconnect/unpair device
236
- program
237
- .command('disconnect')
238
- .description('Disconnect and unpair device')
239
- .action(async () => {
240
- websocket_1.wsClient.disconnect();
241
- config_1.config.userId = null;
242
- config_1.config.pairedAt = null;
243
- config_1.config.pairingCode = null;
244
- console.log(chalk_1.default.green('Device disconnected and unpaired.'));
245
- console.log(chalk_1.default.dim('Run "forkoff pair" to pair again.'));
246
- });
247
- // Manage startup registration
248
- program
249
- .command('startup')
250
- .description('Manage automatic startup on login')
251
- .option('--enable', 'Enable automatic startup')
252
- .option('--disable', 'Disable automatic startup')
253
- .option('--status', 'Show startup status (default)')
254
- .action(async (options) => {
255
- if (options.enable) {
256
- try {
257
- await (0, startup_1.enableStartup)();
258
- console.log(chalk_1.default.green('Automatic startup enabled.'));
259
- console.log(chalk_1.default.dim(`Binary: ${(0, startup_1.getBinaryPath)()}`));
260
- console.log(chalk_1.default.dim('ForkOff will connect automatically when you log in.'));
298
+ console.log(` Binary Path: ${chalk_1.default.red('Not found')}`);
261
299
  }
262
- catch (error) {
263
- console.error(chalk_1.default.red(`Failed to enable startup: ${error.message}`));
300
+ console.log(` Platform: ${chalk_1.default.dim(process.platform)}`);
301
+ });
302
+ // Detect and manage AI coding tools
303
+ program
304
+ .command('tools')
305
+ .description('Detect and manage AI coding tools')
306
+ .option('-d, --detect', 'Detect installed AI tools')
307
+ .option('-i, --install-hooks', 'Install ForkOff hooks for Claude Code')
308
+ .option('-u, --uninstall-hooks', 'Remove ForkOff hooks from Claude Code')
309
+ .option('-w, --watch', 'Watch tool status changes')
310
+ .action(async (options) => {
311
+ if (options.installHooks) {
312
+ const spinner = (0, logger_1.createSpinner)('Installing Claude Code hooks...').start();
313
+ try {
314
+ if (!tools_1.claudeHooksManager.canConfigure()) {
315
+ spinner.fail('Claude Code not found');
316
+ console.log(chalk_1.default.yellow('\nClaude Code must be installed to use hooks.'));
317
+ console.log(chalk_1.default.dim('Install Claude Code from: https://claude.ai/download'));
318
+ return;
319
+ }
320
+ await tools_1.claudeHooksManager.installHooks();
321
+ spinner.succeed('Claude Code hooks installed!');
322
+ console.log(chalk_1.default.green('\nForkOff will now receive events from Claude Code.'));
323
+ console.log(chalk_1.default.dim('Run "forkoff connect" to start receiving events.'));
324
+ }
325
+ catch (error) {
326
+ spinner.fail('Failed to install hooks');
327
+ console.error(chalk_1.default.red(error.message));
328
+ }
329
+ return;
264
330
  }
265
- return;
266
- }
267
- if (options.disable) {
268
- try {
269
- await (0, startup_1.disableStartup)();
270
- console.log(chalk_1.default.green('Automatic startup disabled.'));
271
- console.log(chalk_1.default.dim('ForkOff will no longer start on login.'));
331
+ if (options.uninstallHooks) {
332
+ const spinner = (0, logger_1.createSpinner)('Removing Claude Code hooks...').start();
333
+ try {
334
+ await tools_1.claudeHooksManager.uninstallHooks();
335
+ spinner.succeed('Claude Code hooks removed!');
336
+ }
337
+ catch (error) {
338
+ spinner.fail('Failed to remove hooks');
339
+ console.error(chalk_1.default.red(error.message));
340
+ }
341
+ return;
272
342
  }
273
- catch (error) {
274
- console.error(chalk_1.default.red(`Failed to disable startup: ${error.message}`));
343
+ if (options.watch) {
344
+ console.log(chalk_1.default.bold('\nWatching for tool status changes...'));
345
+ console.log(chalk_1.default.dim('Press Ctrl+C to stop\n'));
346
+ tools_1.toolDetector.watchToolStatus((tools) => {
347
+ console.log(chalk_1.default.cyan(`[${new Date().toLocaleTimeString()}] Tool status update:`));
348
+ tools.forEach(tool => {
349
+ const statusColor = tool.status === 'running' ? chalk_1.default.green :
350
+ tool.status === 'configured' ? chalk_1.default.yellow : chalk_1.default.dim;
351
+ console.log(` ${tool.name}: ${statusColor(tool.status)}`);
352
+ });
353
+ console.log();
354
+ }, 3000);
355
+ // Keep alive
356
+ await new Promise(() => { });
357
+ return;
275
358
  }
276
- return;
277
- }
278
- // Default: show status
279
- const registered = (0, startup_1.isStartupRegistered)();
280
- const configState = config_1.config.startupEnabled;
281
- console.log(chalk_1.default.bold('\nStartup Status:'));
282
- console.log(` OS Registration: ${registered ? chalk_1.default.green('Registered') : chalk_1.default.yellow('Not registered')}`);
283
- console.log(` Config State: ${configState === null ? chalk_1.default.dim('Not configured') :
284
- configState ? chalk_1.default.green('Enabled') : chalk_1.default.yellow('Disabled')}`);
285
- try {
286
- console.log(` Binary Path: ${chalk_1.default.dim((0, startup_1.getBinaryPath)())}`);
287
- }
288
- catch {
289
- console.log(` Binary Path: ${chalk_1.default.red('Not found')}`);
290
- }
291
- console.log(` Platform: ${chalk_1.default.dim(process.platform)}`);
292
- });
293
- // Detect and manage AI coding tools
294
- program
295
- .command('tools')
296
- .description('Detect and manage AI coding tools')
297
- .option('-d, --detect', 'Detect installed AI tools')
298
- .option('-i, --install-hooks', 'Install ForkOff hooks for Claude Code')
299
- .option('-u, --uninstall-hooks', 'Remove ForkOff hooks from Claude Code')
300
- .option('-w, --watch', 'Watch tool status changes')
301
- .action(async (options) => {
302
- if (options.installHooks) {
303
- const spinner = (0, logger_1.createSpinner)('Installing Claude Code hooks...').start();
359
+ // Default: detect tools
360
+ const spinner = (0, logger_1.createSpinner)('Detecting AI coding tools...').start();
304
361
  try {
305
- if (!tools_1.claudeHooksManager.canConfigure()) {
306
- spinner.fail('Claude Code not found');
307
- console.log(chalk_1.default.yellow('\nClaude Code must be installed to use hooks.'));
308
- console.log(chalk_1.default.dim('Install Claude Code from: https://claude.ai/download'));
309
- return;
362
+ const result = await tools_1.toolDetector.detectAll();
363
+ spinner.stop();
364
+ console.log(chalk_1.default.bold('\nDetected AI Coding Tools:\n'));
365
+ if (result.tools.length === 0) {
366
+ console.log(chalk_1.default.yellow(' No AI coding tools detected.'));
367
+ console.log(chalk_1.default.dim('\n Supported tools:'));
368
+ console.log(chalk_1.default.dim(' - Claude Code (https://claude.ai/download)'));
369
+ console.log(chalk_1.default.dim(' - Cursor (https://cursor.sh)'));
370
+ console.log(chalk_1.default.dim(' - GitHub Copilot (VS Code extension)'));
371
+ console.log(chalk_1.default.dim(' - Continue.dev (VS Code extension)'));
310
372
  }
311
- await tools_1.claudeHooksManager.installHooks();
312
- spinner.succeed('Claude Code hooks installed!');
313
- console.log(chalk_1.default.green('\nForkOff will now receive events from Claude Code.'));
314
- console.log(chalk_1.default.dim('Run "forkoff connect" to start receiving events.'));
373
+ else {
374
+ result.tools.forEach(tool => {
375
+ const statusIcon = tool.status === 'running' ? chalk_1.default.green('●') :
376
+ tool.status === 'configured' ? chalk_1.default.yellow('') :
377
+ chalk_1.default.dim('○');
378
+ console.log(` ${statusIcon} ${chalk_1.default.bold(tool.name)}`);
379
+ console.log(` Type: ${chalk_1.default.cyan(tool.type)}`);
380
+ if (tool.version) {
381
+ console.log(` Version: ${chalk_1.default.dim(tool.version)}`);
382
+ }
383
+ if (tool.path) {
384
+ console.log(` Path: ${chalk_1.default.dim(tool.path)}`);
385
+ }
386
+ console.log(` Status: ${tool.status === 'running' ? chalk_1.default.green('Running') :
387
+ tool.status === 'configured' ? chalk_1.default.yellow('Configured') :
388
+ chalk_1.default.dim('Detected')}`);
389
+ // Check if hooks are configured for Claude Code
390
+ if (tool.type === 'claude-code') {
391
+ const hooksConfigured = tools_1.claudeHooksManager.isHookConfigured();
392
+ console.log(` Hooks: ${hooksConfigured ? chalk_1.default.green('Installed') : chalk_1.default.yellow('Not installed')}`);
393
+ if (!hooksConfigured) {
394
+ console.log(chalk_1.default.dim(' Run "forkoff tools --install-hooks" to enable'));
395
+ }
396
+ }
397
+ console.log();
398
+ });
399
+ }
400
+ console.log(chalk_1.default.dim(`Platform: ${result.platform}`));
315
401
  }
316
402
  catch (error) {
317
- spinner.fail('Failed to install hooks');
403
+ spinner.fail('Tool detection failed');
318
404
  console.error(chalk_1.default.red(error.message));
319
405
  }
320
- return;
321
- }
322
- if (options.uninstallHooks) {
323
- const spinner = (0, logger_1.createSpinner)('Removing Claude Code hooks...').start();
406
+ });
407
+ // Helper function to start connection and set up event handlers
408
+ async function startConnection() {
409
+ const spinner = (0, logger_1.createSpinner)('Connecting to ForkOff...').start();
324
410
  try {
325
- await tools_1.claudeHooksManager.uninstallHooks();
326
- spinner.succeed('Claude Code hooks removed!');
327
- }
328
- catch (error) {
329
- spinner.fail('Failed to remove hooks');
330
- console.error(chalk_1.default.red(error.message));
331
- }
332
- return;
333
- }
334
- if (options.watch) {
335
- console.log(chalk_1.default.bold('\nWatching for tool status changes...'));
336
- console.log(chalk_1.default.dim('Press Ctrl+C to stop\n'));
337
- tools_1.toolDetector.watchToolStatus((tools) => {
338
- console.log(chalk_1.default.cyan(`[${new Date().toLocaleTimeString()}] Tool status update:`));
339
- tools.forEach(tool => {
340
- const statusColor = tool.status === 'running' ? chalk_1.default.green :
341
- tool.status === 'configured' ? chalk_1.default.yellow : chalk_1.default.dim;
342
- console.log(` ${tool.name}: ${statusColor(tool.status)}`);
343
- });
344
- console.log();
345
- }, 3000);
346
- // Keep alive
347
- await new Promise(() => { });
348
- return;
349
- }
350
- // Default: detect tools
351
- const spinner = (0, logger_1.createSpinner)('Detecting AI coding tools...').start();
352
- try {
353
- const result = await tools_1.toolDetector.detectAll();
354
- spinner.stop();
355
- console.log(chalk_1.default.bold('\nDetected AI Coding Tools:\n'));
356
- if (result.tools.length === 0) {
357
- console.log(chalk_1.default.yellow(' No AI coding tools detected.'));
358
- console.log(chalk_1.default.dim('\n Supported tools:'));
359
- console.log(chalk_1.default.dim(' - Claude Code (https://claude.ai/download)'));
360
- console.log(chalk_1.default.dim(' - Cursor (https://cursor.sh)'));
361
- console.log(chalk_1.default.dim(' - GitHub Copilot (VS Code extension)'));
362
- console.log(chalk_1.default.dim(' - Continue.dev (VS Code extension)'));
363
- }
364
- else {
365
- result.tools.forEach(tool => {
366
- const statusIcon = tool.status === 'running' ? chalk_1.default.green('●') :
367
- tool.status === 'configured' ? chalk_1.default.yellow('○') :
368
- chalk_1.default.dim('○');
369
- console.log(` ${statusIcon} ${chalk_1.default.bold(tool.name)}`);
370
- console.log(` Type: ${chalk_1.default.cyan(tool.type)}`);
371
- if (tool.version) {
372
- console.log(` Version: ${chalk_1.default.dim(tool.version)}`);
373
- }
374
- if (tool.path) {
375
- console.log(` Path: ${chalk_1.default.dim(tool.path)}`);
411
+ await websocket_1.wsClient.connect();
412
+ tools_1.PermissionIpcManager.cleanupStaleTempFiles();
413
+ spinner.succeed('Connected to ForkOff!\n');
414
+ // Detect and report connected tools
415
+ spinner.start('Detecting AI coding tools...');
416
+ try {
417
+ const toolResult = await tools_1.toolDetector.detectAll();
418
+ 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
+ spinner.succeed(`Detected ${toolResult.tools.length} AI tool(s): ${toolResult.tools.map(t => t.name).join(', ')}`);
376
426
  }
377
- console.log(` Status: ${tool.status === 'running' ? chalk_1.default.green('Running') :
378
- tool.status === 'configured' ? chalk_1.default.yellow('Configured') :
379
- chalk_1.default.dim('Detected')}`);
380
- // Check if hooks are configured for Claude Code
381
- if (tool.type === 'claude-code') {
382
- const hooksConfigured = tools_1.claudeHooksManager.isHookConfigured();
383
- console.log(` Hooks: ${hooksConfigured ? chalk_1.default.green('Installed') : chalk_1.default.yellow('Not installed')}`);
384
- if (!hooksConfigured) {
385
- console.log(chalk_1.default.dim(' Run "forkoff tools --install-hooks" to enable'));
386
- }
427
+ else {
428
+ spinner.info('No AI coding tools detected');
387
429
  }
388
- console.log();
389
- });
390
- }
391
- console.log(chalk_1.default.dim(`Platform: ${result.platform}`));
392
- }
393
- catch (error) {
394
- spinner.fail('Tool detection failed');
395
- console.error(chalk_1.default.red(error.message));
396
- }
397
- });
398
- // Helper function to start connection and set up event handlers
399
- async function startConnection() {
400
- const spinner = (0, logger_1.createSpinner)('Connecting to ForkOff...').start();
401
- try {
402
- await websocket_1.wsClient.connect();
403
- spinner.succeed('Connected to ForkOff!\n');
404
- // Detect and report connected tools
405
- spinner.start('Detecting AI coding tools...');
406
- try {
407
- const toolResult = await tools_1.toolDetector.detectAll();
408
- if (toolResult.tools.length > 0) {
409
- const toolsToReport = toolResult.tools.map(tool => ({
410
- type: tool.type,
411
- name: tool.name,
412
- version: tool.version || null,
413
- }));
414
- await api_1.api.reportConnectedTools(config_1.config.deviceId, toolsToReport);
415
- spinner.succeed(`Detected ${toolResult.tools.length} AI tool(s): ${toolResult.tools.map(t => t.name).join(', ')}`);
416
430
  }
417
- else {
418
- spinner.info('No AI coding tools detected');
431
+ catch (toolError) {
432
+ spinner.warn('Tool detection skipped: ' + (toolError.message || 'unknown error'));
419
433
  }
420
- }
421
- catch (toolError) {
422
- spinner.warn('Tool detection skipped: ' + (toolError.message || 'unknown error'));
423
- }
424
- console.log();
425
- console.log(chalk_1.default.green('Device is now online and ready to receive commands.'));
426
- console.log(chalk_1.default.dim('Press Ctrl+C to disconnect\n'));
427
- // Set up terminal output forwarding
428
- terminal_1.terminalManager.on('output', (data) => {
429
- websocket_1.wsClient.sendTerminalOutput(data);
430
- });
431
- terminal_1.terminalManager.on('cwd_changed', (data) => {
432
- websocket_1.wsClient.sendTerminalCwd(data);
433
- });
434
- // When a session is auto-created (command received before terminal_create), send the cwd
435
- terminal_1.terminalManager.on('session_created', (data) => {
436
- console.log(chalk_1.default.dim(`[Terminal] Session auto-created: ${data.terminalSessionId} at ${data.cwd}`));
437
- websocket_1.wsClient.sendTerminalCwd({
438
- terminalSessionId: data.terminalSessionId,
439
- cwd: data.cwd,
434
+ console.log();
435
+ console.log(chalk_1.default.green('Device is now online and ready to receive commands.'));
436
+ console.log(chalk_1.default.dim('Press Ctrl+C to disconnect\n'));
437
+ // Set up terminal output forwarding
438
+ terminal_1.terminalManager.on('output', (data) => {
439
+ websocket_1.wsClient.sendTerminalOutput(data);
440
440
  });
441
- });
442
- // Set up terminal create handler
443
- websocket_1.wsClient.on('terminal_create', (data) => {
444
- console.log(chalk_1.default.blue(`[Terminal] Creating session: ${data.terminalSessionId}`));
445
- // Resolve the cwd (~ to home directory)
446
- let resolvedCwd = data.cwd || process.cwd();
447
- if (resolvedCwd === '~' || resolvedCwd.startsWith('~/')) {
448
- const homedir = require('os').homedir();
449
- resolvedCwd = resolvedCwd === '~' ? homedir : resolvedCwd.replace('~', homedir);
450
- }
451
- // Create the session
452
- const session = terminal_1.terminalManager.createSession(data.terminalSessionId, resolvedCwd);
453
- // Send back the resolved cwd
454
- websocket_1.wsClient.sendTerminalCwd({
455
- terminalSessionId: data.terminalSessionId,
456
- cwd: session.cwd,
441
+ terminal_1.terminalManager.on('cwd_changed', (data) => {
442
+ websocket_1.wsClient.sendTerminalCwd(data);
457
443
  });
458
- console.log(chalk_1.default.dim(`[Terminal] Session created with cwd: ${session.cwd}`));
459
- });
460
- // Set up event handlers
461
- websocket_1.wsClient.on('terminal_command', async (data) => {
462
- // Check if this is a Claude terminal session
463
- if (tools_1.claudeProcessManager.isClaudeSession(data.terminalSessionId)) {
464
- // SECURITY: Don't log command content - may contain sensitive data
465
- console.log(chalk_1.default.cyan(`[Claude] Input received (${data.command.length} chars)`));
466
- await tools_1.claudeProcessManager.sendInput(data.terminalSessionId, data.command);
467
- return;
468
- }
469
- // Regular terminal command
470
- // SECURITY: Don't log command content - may contain passwords, API keys, etc.
471
- console.log(chalk_1.default.blue(`[Terminal] Executing command (${data.command.length} chars)`));
472
- try {
473
- const result = await terminal_1.terminalManager.executeCommand(data.terminalSessionId, data.command);
474
- console.log(chalk_1.default.dim(`[Terminal] Exit code: ${result.exitCode}`));
475
- }
476
- catch (error) {
477
- console.error(chalk_1.default.red(`[Terminal] Error: ${error.message}`));
478
- }
479
- });
480
- websocket_1.wsClient.on('approval_response', (data) => {
481
- console.log(chalk_1.default.blue(`[Approval] ${data.status}: ${data.approvalId}`));
482
- approval_1.approvalManager.handleApprovalResponse(data.approvalId, data.status);
483
- });
484
- // Set up Claude session detection
485
- if (tools_1.claudeSessionDetector.isClaudeInstalled()) {
486
- console.log(chalk_1.default.cyan('[Claude] Scanning for Claude sessions...'));
487
- // Attach event listeners BEFORE starting to watch (so we catch initial events)
488
- tools_1.claudeSessionDetector.on('session_detected', (session) => {
489
- console.log(chalk_1.default.cyan(`[Claude] New session detected: ${session.directory}`));
490
- websocket_1.wsClient.sendClaudeSessionUpdate(session);
444
+ // When a session is auto-created (command received before terminal_create), send the cwd
445
+ terminal_1.terminalManager.on('session_created', (data) => {
446
+ console.log(chalk_1.default.dim(`[Terminal] Session auto-created: ${data.terminalSessionId} at ${data.cwd}`));
447
+ websocket_1.wsClient.sendTerminalCwd({
448
+ terminalSessionId: data.terminalSessionId,
449
+ cwd: data.cwd,
450
+ });
451
+ });
452
+ // Set up terminal create handler
453
+ websocket_1.wsClient.on('terminal_create', (data) => {
454
+ console.log(chalk_1.default.blue(`[Terminal] Creating session: ${data.terminalSessionId}`));
455
+ // Resolve the cwd (~ to home directory)
456
+ let resolvedCwd = data.cwd || process.cwd();
457
+ if (resolvedCwd === '~' || resolvedCwd.startsWith('~/')) {
458
+ const homedir = require('os').homedir();
459
+ resolvedCwd = resolvedCwd === '~' ? homedir : resolvedCwd.replace('~', homedir);
460
+ }
461
+ // Create the session
462
+ const session = terminal_1.terminalManager.createSession(data.terminalSessionId, resolvedCwd);
463
+ // Send back the resolved cwd
464
+ websocket_1.wsClient.sendTerminalCwd({
465
+ terminalSessionId: data.terminalSessionId,
466
+ cwd: session.cwd,
467
+ });
468
+ console.log(chalk_1.default.dim(`[Terminal] Session created with cwd: ${session.cwd}`));
491
469
  });
492
- tools_1.claudeSessionDetector.on('session_changed', (session) => {
493
- console.log(chalk_1.default.dim(`[Claude] Session updated: ${session.directory} (${session.state})`));
494
- websocket_1.wsClient.sendClaudeSessionUpdate(session);
470
+ // Set up event handlers
471
+ websocket_1.wsClient.on('terminal_command', async (data) => {
472
+ // Check if this is a Claude terminal session
473
+ if (tools_1.claudeProcessManager.isClaudeSession(data.terminalSessionId)) {
474
+ // SECURITY: Don't log command content - may contain sensitive data
475
+ console.log(chalk_1.default.cyan(`[Claude] Input received (${data.command.length} chars)`));
476
+ await tools_1.claudeProcessManager.sendInput(data.terminalSessionId, data.command);
477
+ return;
478
+ }
479
+ // Regular terminal command
480
+ // SECURITY: Don't log command content - may contain passwords, API keys, etc.
481
+ console.log(chalk_1.default.blue(`[Terminal] Executing command (${data.command.length} chars)`));
482
+ try {
483
+ const result = await terminal_1.terminalManager.executeCommand(data.terminalSessionId, data.command);
484
+ console.log(chalk_1.default.dim(`[Terminal] Exit code: ${result.exitCode}`));
485
+ }
486
+ catch (error) {
487
+ console.error(chalk_1.default.red(`[Terminal] Error: ${error.message}`));
488
+ }
495
489
  });
496
- tools_1.claudeSessionDetector.on('claude_running_changed', (isRunning) => {
497
- console.log(chalk_1.default.cyan(`[Claude] Claude is now ${isRunning ? 'ACTIVE' : 'inactive'}`));
498
- websocket_1.wsClient.sendToolStatusUpdate('claude_code', isRunning ? 'active' : 'inactive');
490
+ websocket_1.wsClient.on('approval_response', (data) => {
491
+ console.log(chalk_1.default.blue(`[Approval] ${data.status}: ${data.approvalId}`));
492
+ approval_1.approvalManager.handleApprovalResponse(data.approvalId, data.status);
499
493
  });
500
- // Scan and report existing sessions
501
- const sessions = tools_1.claudeSessionDetector.scanSessions();
502
- if (sessions.length > 0) {
503
- console.log(chalk_1.default.cyan(`[Claude] Found ${sessions.length} session(s)`));
504
- // Update session states based on file modification time before sending
505
- const now = Date.now();
506
- let hasActiveSession = false;
507
- for (const session of sessions) {
508
- const sessionTime = new Date(session.lastUsedAt).getTime();
509
- if (now - sessionTime < 60000) {
510
- session.state = 'active';
511
- session.lastUsedAt = new Date().toISOString(); // Update to NOW for active sessions
512
- hasActiveSession = true;
494
+ // Set up Claude session detection
495
+ if (tools_1.claudeSessionDetector.isClaudeInstalled()) {
496
+ console.log(chalk_1.default.cyan('[Claude] Scanning for Claude sessions...'));
497
+ // Attach event listeners BEFORE starting to watch (so we catch initial events)
498
+ tools_1.claudeSessionDetector.on('session_detected', (session) => {
499
+ console.log(chalk_1.default.cyan(`[Claude] New session detected: ${session.directory}`));
500
+ websocket_1.wsClient.sendClaudeSessionUpdate(session);
501
+ });
502
+ tools_1.claudeSessionDetector.on('session_changed', (session) => {
503
+ console.log(chalk_1.default.dim(`[Claude] Session updated: ${session.directory} (${session.state})`));
504
+ websocket_1.wsClient.sendClaudeSessionUpdate(session);
505
+ });
506
+ tools_1.claudeSessionDetector.on('claude_running_changed', (isRunning) => {
507
+ console.log(chalk_1.default.cyan(`[Claude] Claude is now ${isRunning ? 'ACTIVE' : 'inactive'}`));
508
+ websocket_1.wsClient.sendToolStatusUpdate('claude_code', isRunning ? 'active' : 'inactive');
509
+ });
510
+ // Scan and report existing sessions
511
+ const sessions = tools_1.claudeSessionDetector.scanSessions();
512
+ if (sessions.length > 0) {
513
+ console.log(chalk_1.default.cyan(`[Claude] Found ${sessions.length} session(s)`));
514
+ // Update session states based on file modification time before sending
515
+ const now = Date.now();
516
+ let hasActiveSession = false;
517
+ for (const session of sessions) {
518
+ const sessionTime = new Date(session.lastUsedAt).getTime();
519
+ if (now - sessionTime < 60000) {
520
+ session.state = 'active';
521
+ hasActiveSession = true;
522
+ }
523
+ }
524
+ websocket_1.wsClient.sendClaudeSessions(sessions);
525
+ if (hasActiveSession) {
526
+ console.log(chalk_1.default.cyan(`[Claude] Claude is now ACTIVE`));
527
+ websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'active');
513
528
  }
529
+ // Seed the cache so startWatching doesn't re-emit these sessions
530
+ // individually as session_detected events
531
+ tools_1.claudeSessionDetector.seedKnownSessions(sessions);
514
532
  }
515
- websocket_1.wsClient.sendClaudeSessions(sessions);
516
- if (hasActiveSession) {
517
- console.log(chalk_1.default.cyan(`[Claude] Claude is now ACTIVE`));
533
+ // Start watching for session changes
534
+ tools_1.claudeSessionDetector.startWatching(5000);
535
+ }
536
+ // Log approval events
537
+ approval_1.approvalManager.on('approved', (approval) => {
538
+ console.log(chalk_1.default.green(`[Approval] Approved: ${approval.description}`));
539
+ });
540
+ approval_1.approvalManager.on('rejected', (approval) => {
541
+ console.log(chalk_1.default.red(`[Approval] Rejected: ${approval.description}`));
542
+ });
543
+ // Handle Claude start session request from mobile
544
+ websocket_1.wsClient.on('claude_start_session', async (data) => {
545
+ console.log(chalk_1.default.cyan(`[Claude] Start session request: ${data.directory}`));
546
+ try {
547
+ const result = await tools_1.claudeProcessManager.startSession(data.directory, data.terminalSessionId, data.dangerouslySkipPermissions, data.interactivePermissions);
518
548
  websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'active');
549
+ websocket_1.wsClient.sendTerminalCwd({ terminalSessionId: data.terminalSessionId, cwd: result.cwd });
550
+ // Notify mobile that the session is ready for input
551
+ websocket_1.wsClient.sendClaudeSessionEvent({
552
+ sessionKey: data.terminalSessionId,
553
+ event: { type: 'ready' },
554
+ });
555
+ console.log(chalk_1.default.green(`[Claude] Session started: ${data.terminalSessionId}`));
519
556
  }
520
- }
521
- // Start watching for session changes
522
- tools_1.claudeSessionDetector.startWatching(5000);
523
- }
524
- // Log approval events
525
- approval_1.approvalManager.on('approved', (approval) => {
526
- console.log(chalk_1.default.green(`[Approval] Approved: ${approval.description}`));
527
- });
528
- approval_1.approvalManager.on('rejected', (approval) => {
529
- console.log(chalk_1.default.red(`[Approval] Rejected: ${approval.description}`));
530
- });
531
- websocket_1.wsClient.on('git_clone', async (data) => {
532
- console.log(chalk_1.default.blue(`[Git] Clone request: ${data.repo.fullName}`));
533
- try {
534
- const result = await terminal_1.terminalManager.executeCommand(`git-clone-${Date.now()}`, data.command);
535
- console.log(chalk_1.default.green(`[Git] Clone completed with exit code: ${result.exitCode}`));
536
- }
537
- catch (error) {
538
- console.error(chalk_1.default.red(`[Git] Clone failed: ${error.message}`));
539
- }
540
- });
541
- // Handle Claude start session request from mobile
542
- websocket_1.wsClient.on('claude_start_session', async (data) => {
543
- console.log(chalk_1.default.cyan(`[Claude] Start session request: ${data.directory}`));
544
- try {
545
- const result = await tools_1.claudeProcessManager.startSession(data.directory, data.terminalSessionId, data.dangerouslySkipPermissions);
557
+ catch (error) {
558
+ console.error(chalk_1.default.red(`[Claude] Failed to start: ${error.message}`));
559
+ }
560
+ });
561
+ // Handle Claude resume session request from mobile
562
+ // NOTE: This is called when mobile opens a session view - we DON'T spawn Claude here
563
+ // Claude is only spawned when the user actually sends a message (via user_message event)
564
+ // This prevents duplicate transcript entries from double spawns
565
+ 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}`));
567
+ // Look up the correct directory from our locally scanned sessions
568
+ // The mobile app may have a cached/stale directory (e.g. with corrupted hyphens)
569
+ let resolvedDir = data.directory;
570
+ const knownSession = tools_1.claudeSessionDetector.getSessions().find(s => s.sessionKey === data.sessionKey);
571
+ if (knownSession && knownSession.directory) {
572
+ console.log(chalk_1.default.dim(`[Claude] Using local directory for session: ${knownSession.directory}`));
573
+ resolvedDir = knownSession.directory;
574
+ }
575
+ if (resolvedDir === '~' || resolvedDir.startsWith('~/')) {
576
+ resolvedDir = resolvedDir === '~' ? os.homedir() : resolvedDir.replace('~', os.homedir());
577
+ }
578
+ resolvedDir = path.resolve(resolvedDir);
579
+ // Always register the session — even if it's running in a terminal.
580
+ // With --resume, we spawn a new process per turn which picks up where the session left off.
581
+ // If the terminal session holds a file lock, the spawn will fail gracefully.
582
+ // isRealSession: true if the key matches a locally-known Claude session, false if mobile-generated
583
+ // (e.g. brainstorm-*, quick actions). Fresh sessions use startAndSendMessage instead of --resume.
584
+ const isRealSession = !!knownSession;
585
+ tools_1.claudeProcessManager.registerSession(data.sessionKey, resolvedDir, data.terminalSessionId, data.dangerouslySkipPermissions, data.interactivePermissions, isRealSession);
586
+ tools_1.claudeProcessManager.markTakenOver(data.terminalSessionId);
546
587
  websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'active');
547
- websocket_1.wsClient.sendTerminalCwd({ terminalSessionId: data.terminalSessionId, cwd: result.cwd });
588
+ websocket_1.wsClient.sendClaudeSessionUpdate({
589
+ sessionKey: data.sessionKey,
590
+ directory: data.directory,
591
+ state: 'active',
592
+ lastUsedAt: new Date().toISOString(),
593
+ });
594
+ websocket_1.wsClient.sendTerminalCwd({ terminalSessionId: data.terminalSessionId, cwd: resolvedDir });
595
+ // Sync any pending permission prompts to mobile
596
+ const pendingPrompts = tools_1.claudeProcessManager.getAllPendingPrompts();
597
+ if (pendingPrompts.length > 0) {
598
+ console.log(chalk_1.default.yellow(`[Claude] Syncing ${pendingPrompts.length} pending permission prompt(s) to mobile`));
599
+ websocket_1.wsClient.sendPendingPermissionsSync({
600
+ sessionKey: data.sessionKey,
601
+ terminalSessionId: data.terminalSessionId,
602
+ prompts: pendingPrompts,
603
+ });
604
+ }
548
605
  // Notify mobile that the session is ready for input
549
606
  websocket_1.wsClient.sendClaudeSessionEvent({
550
- sessionKey: data.terminalSessionId,
607
+ sessionKey: data.sessionKey,
551
608
  event: { type: 'ready' },
552
609
  });
553
- console.log(chalk_1.default.green(`[Claude] Session started: ${data.terminalSessionId}`));
554
- }
555
- catch (error) {
556
- console.error(chalk_1.default.red(`[Claude] Failed to start: ${error.message}`));
557
- }
558
- });
559
- // Handle Claude resume session request from mobile
560
- // NOTE: This is called when mobile opens a session view - we DON'T spawn Claude here
561
- // Claude is only spawned when the user actually sends a message (via user_message event)
562
- // This prevents duplicate transcript entries from double spawns
563
- websocket_1.wsClient.on('claude_resume_session', async (data) => {
564
- console.log(chalk_1.default.cyan(`[Claude] Resume session request: ${data.sessionKey} in ${data.directory}`));
565
- // Look up the correct directory from our locally scanned sessions
566
- // The mobile app may have a cached/stale directory (e.g. with corrupted hyphens)
567
- let resolvedDir = data.directory;
568
- const knownSession = tools_1.claudeSessionDetector.getSessions().find(s => s.sessionKey === data.sessionKey);
569
- if (knownSession && knownSession.directory) {
570
- console.log(chalk_1.default.dim(`[Claude] Using local directory for session: ${knownSession.directory}`));
571
- resolvedDir = knownSession.directory;
572
- }
573
- if (resolvedDir === '~' || resolvedDir.startsWith('~/')) {
574
- resolvedDir = resolvedDir === '~' ? os.homedir() : resolvedDir.replace('~', os.homedir());
575
- }
576
- resolvedDir = path.resolve(resolvedDir);
577
- // Register session info for later use when message is sent (don't spawn yet)
578
- tools_1.claudeProcessManager.registerSession(data.sessionKey, resolvedDir, data.terminalSessionId, data.dangerouslySkipPermissions);
579
- websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'active');
580
- websocket_1.wsClient.sendClaudeSessionUpdate({
581
- sessionKey: data.sessionKey,
582
- directory: data.directory,
583
- state: 'active',
584
- lastUsedAt: new Date().toISOString(),
585
- });
586
- websocket_1.wsClient.sendTerminalCwd({ terminalSessionId: data.terminalSessionId, cwd: resolvedDir });
587
- // Notify mobile that the session is ready for input
588
- websocket_1.wsClient.sendClaudeSessionEvent({
589
- sessionKey: data.sessionKey,
590
- event: { type: 'ready' },
610
+ console.log(chalk_1.default.green(`[Claude] Session ready (will spawn on first message): ${data.sessionKey}`));
591
611
  });
592
- console.log(chalk_1.default.green(`[Claude] Session ready (will spawn on first message): ${data.sessionKey}`));
593
- });
594
- // Handle directory listing requests
595
- websocket_1.wsClient.on('directory_list', async (data) => {
596
- console.log(chalk_1.default.dim(`[Dir] Listing request received`));
597
- try {
598
- let resolvedPath = data.path;
599
- if (resolvedPath === '~' || resolvedPath.startsWith('~/')) {
600
- resolvedPath = resolvedPath === '~' ? os.homedir() : resolvedPath.replace('~', os.homedir());
612
+ // Handle directory listing requests
613
+ websocket_1.wsClient.on('directory_list', async (data) => {
614
+ console.log(chalk_1.default.dim(`[Dir] Listing request received`));
615
+ try {
616
+ let resolvedPath = data.path;
617
+ if (resolvedPath === '~' || resolvedPath.startsWith('~/')) {
618
+ resolvedPath = resolvedPath === '~' ? os.homedir() : resolvedPath.replace('~', os.homedir());
619
+ }
620
+ // SECURITY: Normalize and validate path to prevent traversal attacks
621
+ resolvedPath = path.resolve(resolvedPath);
622
+ const homeDir = os.homedir();
623
+ // SECURITY: Only allow access to directories under home directory
624
+ // 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}`));
627
+ websocket_1.wsClient.sendDirectoryListResponse({ requestId: data.requestId, entries: [], currentPath: data.path });
628
+ return;
629
+ }
630
+ const entries = fs.readdirSync(resolvedPath, { withFileTypes: true })
631
+ .filter(entry => !entry.name.startsWith('.'))
632
+ .map(entry => ({
633
+ name: entry.name,
634
+ type: entry.isDirectory() ? 'directory' : 'file',
635
+ path: path.join(resolvedPath, entry.name),
636
+ }))
637
+ .sort((a, b) => {
638
+ if (a.type !== b.type)
639
+ return a.type === 'directory' ? -1 : 1;
640
+ return a.name.localeCompare(b.name);
641
+ });
642
+ websocket_1.wsClient.sendDirectoryListResponse({ requestId: data.requestId, entries, currentPath: resolvedPath });
601
643
  }
602
- // SECURITY: Normalize and validate path to prevent traversal attacks
603
- resolvedPath = path.resolve(resolvedPath);
604
- const homeDir = os.homedir();
605
- // SECURITY: Only allow access to directories under home directory
606
- // This prevents accessing sensitive system files like /etc/passwd
607
- if (!resolvedPath.startsWith(homeDir)) {
608
- console.warn(chalk_1.default.yellow(`[Dir] Access denied - path outside home directory: ${resolvedPath}`));
644
+ catch (error) {
645
+ console.error(chalk_1.default.red(`[Dir] Error: ${error.message}`));
609
646
  websocket_1.wsClient.sendDirectoryListResponse({ requestId: data.requestId, entries: [], currentPath: data.path });
610
- return;
611
647
  }
612
- const entries = fs.readdirSync(resolvedPath, { withFileTypes: true })
613
- .filter(entry => !entry.name.startsWith('.'))
614
- .map(entry => ({
615
- name: entry.name,
616
- type: entry.isDirectory() ? 'directory' : 'file',
617
- path: path.join(resolvedPath, entry.name),
618
- }))
619
- .sort((a, b) => {
620
- if (a.type !== b.type)
621
- return a.type === 'directory' ? -1 : 1;
622
- return a.name.localeCompare(b.name);
623
- });
624
- websocket_1.wsClient.sendDirectoryListResponse({ requestId: data.requestId, entries, currentPath: resolvedPath });
625
- }
626
- catch (error) {
627
- console.error(chalk_1.default.red(`[Dir] Error: ${error.message}`));
628
- websocket_1.wsClient.sendDirectoryListResponse({ requestId: data.requestId, entries: [], currentPath: data.path });
629
- }
630
- });
631
- // Handle read file requests from mobile (e.g., CLAUDE.md)
632
- websocket_1.wsClient.on('read_file', async (data) => {
633
- console.log(chalk_1.default.dim(`[File] Read request: ${data.filePath}`));
634
- try {
635
- // SECURITY: Whitelist of allowed filenames
636
- const allowedFiles = ['CLAUDE.md', 'README.md', 'package.json'];
637
- const fileName = path.basename(data.filePath);
638
- if (!allowedFiles.includes(fileName)) {
639
- console.warn(chalk_1.default.yellow(`[File] Access denied - file not in whitelist: ${fileName}`));
648
+ });
649
+ // Handle read file requests from mobile (e.g., CLAUDE.md)
650
+ websocket_1.wsClient.on('read_file', async (data) => {
651
+ console.log(chalk_1.default.dim(`[File] Read request: ${data.filePath}`));
652
+ try {
653
+ // SECURITY: Whitelist of allowed filenames
654
+ const allowedFiles = ['CLAUDE.md', 'README.md', 'package.json'];
655
+ const fileName = path.basename(data.filePath);
656
+ if (!allowedFiles.includes(fileName)) {
657
+ console.warn(chalk_1.default.yellow(`[File] Access denied - file not in whitelist: ${fileName}`));
658
+ websocket_1.wsClient.sendReadFileResponse({
659
+ requestId: data.requestId,
660
+ exists: false,
661
+ fileName,
662
+ error: 'File not allowed',
663
+ });
664
+ return;
665
+ }
666
+ // Resolve path
667
+ let resolvedPath = data.filePath;
668
+ if (resolvedPath === '~' || resolvedPath.startsWith('~/')) {
669
+ resolvedPath = resolvedPath === '~' ? os.homedir() : resolvedPath.replace('~', os.homedir());
670
+ }
671
+ resolvedPath = path.resolve(resolvedPath);
672
+ // SECURITY: Only allow access under home directory
673
+ const homeDir = os.homedir();
674
+ if (!resolvedPath.startsWith(homeDir)) {
675
+ console.warn(chalk_1.default.yellow(`[File] Access denied - path outside home directory: ${resolvedPath}`));
676
+ websocket_1.wsClient.sendReadFileResponse({
677
+ requestId: data.requestId,
678
+ exists: false,
679
+ fileName,
680
+ error: 'Path outside home directory',
681
+ });
682
+ return;
683
+ }
684
+ // Check if file exists
685
+ if (!fs.existsSync(resolvedPath)) {
686
+ websocket_1.wsClient.sendReadFileResponse({
687
+ requestId: data.requestId,
688
+ exists: false,
689
+ fileName,
690
+ });
691
+ return;
692
+ }
693
+ // SECURITY: Check file size (max 100KB)
694
+ const stats = fs.statSync(resolvedPath);
695
+ if (stats.size > 100 * 1024) {
696
+ websocket_1.wsClient.sendReadFileResponse({
697
+ requestId: data.requestId,
698
+ exists: true,
699
+ fileName,
700
+ error: 'File too large (max 100KB)',
701
+ });
702
+ return;
703
+ }
704
+ const content = fs.readFileSync(resolvedPath, 'utf-8');
640
705
  websocket_1.wsClient.sendReadFileResponse({
641
706
  requestId: data.requestId,
642
- exists: false,
707
+ content,
708
+ exists: true,
643
709
  fileName,
644
- error: 'File not allowed',
645
710
  });
646
- return;
647
- }
648
- // Resolve path
649
- let resolvedPath = data.filePath;
650
- if (resolvedPath === '~' || resolvedPath.startsWith('~/')) {
651
- resolvedPath = resolvedPath === '~' ? os.homedir() : resolvedPath.replace('~', os.homedir());
652
711
  }
653
- resolvedPath = path.resolve(resolvedPath);
654
- // SECURITY: Only allow access under home directory
655
- const homeDir = os.homedir();
656
- if (!resolvedPath.startsWith(homeDir)) {
657
- console.warn(chalk_1.default.yellow(`[File] Access denied - path outside home directory: ${resolvedPath}`));
712
+ catch (error) {
713
+ console.error(chalk_1.default.red(`[File] Error: ${error.message}`));
658
714
  websocket_1.wsClient.sendReadFileResponse({
659
715
  requestId: data.requestId,
660
716
  exists: false,
661
- fileName,
662
- error: 'Path outside home directory',
717
+ fileName: path.basename(data.filePath),
718
+ error: error.message,
663
719
  });
664
- return;
665
720
  }
666
- // Check if file exists
667
- if (!fs.existsSync(resolvedPath)) {
668
- websocket_1.wsClient.sendReadFileResponse({
669
- requestId: data.requestId,
670
- exists: false,
671
- fileName,
721
+ });
722
+ // Handle transcript fetch requests from mobile
723
+ 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}`));
725
+ 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)
727
+ );
728
+ const payload = JSON.stringify(result.entries);
729
+ console.log(chalk_1.default.dim(`[Transcript] Sending history: ${result.entries.length} entries, ${result.totalEntries} total, payload ~${(payload.length / 1024).toFixed(0)}KB`));
730
+ websocket_1.wsClient.sendTranscriptHistory({
731
+ sessionKey: data.sessionKey,
732
+ ...result,
733
+ offset: data.offset || 0,
734
+ requestedBy: data.requestedBy,
672
735
  });
673
- return;
674
736
  }
675
- // SECURITY: Check file size (max 100KB)
676
- const stats = fs.statSync(resolvedPath);
677
- if (stats.size > 100 * 1024) {
678
- websocket_1.wsClient.sendReadFileResponse({
679
- requestId: data.requestId,
680
- exists: true,
681
- fileName,
682
- error: 'File too large (max 100KB)',
683
- });
684
- return;
737
+ catch (error) {
738
+ console.error(chalk_1.default.red(`[Transcript] Error: ${error.message}`));
685
739
  }
686
- const content = fs.readFileSync(resolvedPath, 'utf-8');
687
- websocket_1.wsClient.sendReadFileResponse({
688
- requestId: data.requestId,
689
- content,
690
- exists: true,
691
- fileName,
692
- });
693
- }
694
- catch (error) {
695
- console.error(chalk_1.default.red(`[File] Error: ${error.message}`));
696
- websocket_1.wsClient.sendReadFileResponse({
697
- requestId: data.requestId,
698
- exists: false,
699
- fileName: path.basename(data.filePath),
700
- error: error.message,
701
- });
702
- }
703
- });
704
- // Handle transcript fetch requests from mobile
705
- websocket_1.wsClient.on('transcript_fetch', async (data) => {
706
- console.log(chalk_1.default.dim(`[Transcript] Fetching: ${data.sessionKey}, offset: ${data.offset}, limit: ${data.limit}, reverse: ${data.reverse}`));
707
- try {
708
- 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)
709
- );
710
- websocket_1.wsClient.sendTranscriptHistory({
711
- sessionKey: data.sessionKey,
712
- ...result,
713
- offset: data.offset || 0,
714
- });
715
- }
716
- catch (error) {
717
- console.error(chalk_1.default.red(`[Transcript] Error: ${error.message}`));
718
- }
719
- });
720
- // Handle transcript subscribe
721
- websocket_1.wsClient.on('transcript_subscribe', (data) => {
722
- console.log(chalk_1.default.dim(`[Transcript] Subscribing: ${data.sessionKey}`));
723
- transcript_streamer_1.transcriptStreamer.subscribeToUpdates(data.sessionKey, data.transcriptPath);
724
- });
725
- // Handle transcript unsubscribe
726
- websocket_1.wsClient.on('transcript_unsubscribe', (data) => {
727
- console.log(chalk_1.default.dim(`[Transcript] Unsubscribing: ${data.sessionKey}`));
728
- transcript_streamer_1.transcriptStreamer.unsubscribeFromUpdates(data.sessionKey);
729
- });
730
- // Handle SDK subscribe start - mobile wants live updates for a session
731
- // This is sent by API when mobile uses transcript_subscribe_sdk
732
- websocket_1.wsClient.on('transcript_subscribe_sdk_start', async (data) => {
733
- console.log(chalk_1.default.cyan(`[Transcript] SDK subscribe start: ${data.sessionKey}`));
734
- // Find the transcript file for this session
735
- const sessions = tools_1.claudeSessionDetector.scanSessions();
736
- const session = sessions.find(s => s.sessionKey === data.sessionKey);
737
- if (session?.transcriptPath) {
738
- console.log(chalk_1.default.dim(`[Transcript] Starting watch for: ${session.transcriptPath}`));
739
- transcript_streamer_1.transcriptStreamer.subscribeToUpdates(data.sessionKey, session.transcriptPath);
740
- }
741
- else {
742
- console.log(chalk_1.default.yellow(`[Transcript] No transcript found for session: ${data.sessionKey}`));
743
- }
744
- });
745
- // Handle claude sessions request - mobile app wants current sessions
746
- websocket_1.wsClient.on('claude_sessions_request', () => {
747
- console.log(chalk_1.default.cyan(`[Claude] Sessions requested by mobile`));
748
- if (tools_1.claudeSessionDetector.isClaudeInstalled()) {
740
+ });
741
+ // Handle transcript subscribe
742
+ 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);
745
+ });
746
+ // Handle transcript unsubscribe
747
+ websocket_1.wsClient.on('transcript_unsubscribe', (data) => {
748
+ console.log(chalk_1.default.dim(`[Transcript] Unsubscribing: ${data.sessionKey}`));
749
+ transcript_streamer_1.transcriptStreamer.unsubscribeFromUpdates(data.sessionKey);
750
+ });
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
749
756
  const sessions = tools_1.claudeSessionDetector.scanSessions();
750
- const now = Date.now();
751
- let hasActiveSession = false;
752
- // Update session states based on file modification time
753
- for (const session of sessions) {
754
- const sessionTime = new Date(session.lastUsedAt).getTime();
755
- if (now - sessionTime < 60000) {
756
- session.state = 'active';
757
- session.lastUsedAt = new Date().toISOString(); // Update to NOW for active sessions
758
- hasActiveSession = true;
759
- }
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);
760
761
  }
761
- // Send sessions
762
- if (sessions.length > 0) {
763
- websocket_1.wsClient.sendClaudeSessions(sessions);
762
+ else {
763
+ console.log(chalk_1.default.yellow(`[Transcript] No transcript found for session: ${data.sessionKey}`));
764
764
  }
765
- // Send tool status
766
- websocket_1.wsClient.sendToolStatusUpdate('claude_code', hasActiveSession ? 'active' : 'inactive');
767
- }
768
- });
769
- // Handle RPC requests from the API gateway
770
- websocket_1.wsClient.on('rpc_request', async (data) => {
771
- console.log(chalk_1.default.cyan(`[RPC] Request: ${data.method}, requestId: ${data.requestId}`));
772
- try {
773
- if (data.method === 'get_session_history') {
774
- const { claudeSessionId, sessionKey, limit = 400, offset = 0 } = data.params;
775
- console.log(chalk_1.default.dim(`[RPC] get_session_history request received`));
776
- // Find the transcript file
777
- let transcriptPath;
778
- // If claudeSessionId is provided, search for the JSONL file directly
779
- if (claudeSessionId) {
780
- // SECURITY: Validate claudeSessionId to prevent path traversal
781
- // Session IDs should be alphanumeric with hyphens/underscores only
782
- const sessionIdRegex = /^[a-zA-Z0-9_-]+$/;
783
- if (!sessionIdRegex.test(claudeSessionId)) {
784
- console.warn(chalk_1.default.yellow(`[RPC] Invalid claudeSessionId format - rejected`));
785
- websocket_1.wsClient.sendRpcResponse({
786
- requestId: data.requestId,
787
- error: { code: -32602, message: 'Invalid session ID format' }
788
- });
789
- return;
765
+ });
766
+ // Handle claude sessions request - mobile app wants current sessions
767
+ websocket_1.wsClient.on('claude_sessions_request', () => {
768
+ console.log(chalk_1.default.cyan(`[Claude] Sessions requested by mobile`));
769
+ if (tools_1.claudeSessionDetector.isClaudeInstalled()) {
770
+ const sessions = tools_1.claudeSessionDetector.scanSessions();
771
+ const now = Date.now();
772
+ let hasActiveSession = false;
773
+ // Update session states based on file modification time
774
+ for (const session of sessions) {
775
+ const sessionTime = new Date(session.lastUsedAt).getTime();
776
+ if (now - sessionTime < 60000) {
777
+ session.state = 'active';
778
+ hasActiveSession = true;
790
779
  }
791
- const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
792
- if (fs.existsSync(claudeProjectsDir)) {
793
- const projectDirs = fs.readdirSync(claudeProjectsDir);
794
- for (const projectDir of projectDirs) {
795
- // SECURITY: Validate projectDir as well to prevent traversal
796
- if (!sessionIdRegex.test(projectDir) && !/^[a-zA-Z0-9_.-]+$/.test(projectDir)) {
797
- continue;
780
+ }
781
+ // Send sessions
782
+ if (sessions.length > 0) {
783
+ websocket_1.wsClient.sendClaudeSessions(sessions);
784
+ }
785
+ // Send tool status
786
+ websocket_1.wsClient.sendToolStatusUpdate('claude_code', hasActiveSession ? 'active' : 'inactive');
787
+ }
788
+ });
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
+ }
798
830
  }
799
- const potentialPath = path.join(claudeProjectsDir, projectDir, `${claudeSessionId}.jsonl`);
800
- // SECURITY: Verify the resolved path is still under claudeProjectsDir
801
- const resolvedPotentialPath = path.resolve(potentialPath);
802
- if (!resolvedPotentialPath.startsWith(claudeProjectsDir)) {
803
- continue;
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}`));
804
844
  }
805
- if (fs.existsSync(potentialPath)) {
806
- transcriptPath = potentialPath;
807
- console.log(chalk_1.default.dim(`[RPC] Found JSONL transcript`));
808
- break;
845
+ else {
846
+ console.log(chalk_1.default.dim(`[RPC] No session found for directory: ${normalizedDir}`));
809
847
  }
810
848
  }
849
+ if (session?.transcriptPath) {
850
+ transcriptPath = session.transcriptPath;
851
+ console.log(chalk_1.default.dim(`[RPC] Found transcript path: ${transcriptPath}`));
852
+ }
811
853
  }
812
- }
813
- // If not found by claudeSessionId, search all sessions
814
- if (!transcriptPath && tools_1.claudeSessionDetector.isClaudeInstalled()) {
815
- const sessions = tools_1.claudeSessionDetector.scanSessions();
816
- // First try to match by sessionKey
817
- let session = sessions.find(s => s.sessionKey === sessionKey);
818
- // If no match by sessionKey, try to find most recent session for the same directory
819
- if (!session && sessions.length > 0) {
820
- // Just use the most recent session as fallback
821
- session = sessions[0];
822
- console.log(chalk_1.default.dim(`[RPC] Using most recent session as fallback: ${session.sessionKey}`));
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;
823
861
  }
824
- if (session?.transcriptPath) {
825
- transcriptPath = session.transcriptPath;
826
- console.log(chalk_1.default.dim(`[RPC] Found transcript path: ${transcriptPath}`));
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`));
827
871
  }
828
- }
829
- if (!transcriptPath || !fs.existsSync(transcriptPath)) {
830
- console.log(chalk_1.default.yellow(`[RPC] No transcript file found for session`));
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);
831
875
  websocket_1.wsClient.sendRpcResponse({
832
876
  requestId: data.requestId,
833
- result: { entries: [], totalEntries: 0, hasMore: false }
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
+ }
834
883
  });
835
- return;
836
884
  }
837
- // Read the transcript file
838
- const result = await transcript_streamer_1.transcriptStreamer.fetchHistory(transcriptPath, offset, limit, true);
839
- console.log(chalk_1.default.green(`[RPC] Loaded ${result.entries.length} entries from transcript`));
840
- // IMPORTANT: Start watching this transcript for live updates
841
- // This is needed because SDK mode doesn't send transcript_subscribe
842
- // Use sessionKey from mobile for updates (must match what mobile is listening for)
843
- const updateSessionKey = sessionKey;
844
- if (!updateSessionKey) {
845
- console.log(chalk_1.default.yellow(`[RPC] No sessionKey provided, using claudeSessionId - updates may not route correctly`));
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
+ });
846
891
  }
847
- const watchKey = updateSessionKey || claudeSessionId || data.requestId;
848
- console.log(chalk_1.default.cyan(`[RPC] Starting file watch for live updates: ${watchKey}`));
849
- transcript_streamer_1.transcriptStreamer.subscribeToUpdates(watchKey, transcriptPath);
892
+ }
893
+ catch (error) {
894
+ console.error(chalk_1.default.red(`[RPC] Error handling ${data.method}:`, error.message));
850
895
  websocket_1.wsClient.sendRpcResponse({
851
896
  requestId: data.requestId,
852
- result: {
853
- entries: result.entries,
854
- totalEntries: result.totalEntries,
855
- hasMore: result.hasMore,
856
- sessionKey: watchKey, // Tell mobile which sessionKey to listen for updates
897
+ error: { code: -32603, message: error.message || 'Internal error' }
898
+ });
899
+ }
900
+ });
901
+ // Forward live transcript updates to WebSocket
902
+ transcript_streamer_1.transcriptStreamer.on('update', (data) => {
903
+ console.log(chalk_1.default.green(`[Transcript] Sending update for ${data.sessionKey}: ${data.entry?.type}`));
904
+ websocket_1.wsClient.sendTranscriptUpdate(data);
905
+ // Also update session lastUsedAt to keep it fresh
906
+ // Find the session to get the directory
907
+ const sessions = tools_1.claudeSessionDetector.getSessions();
908
+ const session = sessions.find(s => s.sessionKey === data.sessionKey);
909
+ if (session) {
910
+ websocket_1.wsClient.sendClaudeSessionUpdate({
911
+ sessionKey: data.sessionKey,
912
+ directory: session.directory,
913
+ state: 'active',
914
+ lastUsedAt: new Date().toISOString(),
915
+ transcriptPath: session.transcriptPath,
916
+ });
917
+ }
918
+ });
919
+ // Forward Claude process output to WebSocket
920
+ tools_1.claudeProcessManager.on('output', (data) => {
921
+ websocket_1.wsClient.sendTerminalOutput(data);
922
+ });
923
+ // Forward Claude approval requests to WebSocket (mobile approval)
924
+ tools_1.claudeProcessManager.on('claude_approval_request', (data) => {
925
+ console.log(chalk_1.default.yellow(`[Claude] Approval request: ${data.approvalId}`));
926
+ websocket_1.wsClient.sendClaudeApprovalRequest(data);
927
+ });
928
+ // Forward tool activity events to mobile (non-blocking notifications)
929
+ tools_1.claudeProcessManager.on('tool_activity', (data) => {
930
+ console.log(chalk_1.default.dim(`[Claude] Tool activity: ${data.toolName} - ${data.inputSummary?.substring(0, 60)}`));
931
+ websocket_1.wsClient.sendToolActivity(data);
932
+ });
933
+ // Forward permission prompts from hook system to mobile (interactive approval)
934
+ // Only forward if the session is taken over; otherwise auto-allow so Claude doesn't hang
935
+ tools_1.claudeProcessManager.on('permission_prompt', (data) => {
936
+ if (!tools_1.claudeProcessManager.isTakenOver(data.terminalSessionId)) {
937
+ console.log(chalk_1.default.dim(`[Claude] Permission prompt auto-allowed (watch-only): ${data.toolName} (${data.promptId})`));
938
+ tools_1.claudeProcessManager.handlePermissionResponse(data.promptId, 'allow', 'Auto-allowed: session not taken over');
939
+ return;
940
+ }
941
+ console.log(chalk_1.default.yellow(`[Claude] Permission prompt: ${data.toolName} (${data.promptId})`));
942
+ websocket_1.wsClient.sendPermissionPrompt(data);
943
+ });
944
+ // Handle permission responses from mobile → route back to hook IPC
945
+ websocket_1.wsClient.on('permission_response', (data) => {
946
+ console.log(chalk_1.default.green(`[Claude] Permission response: ${data.promptId} -> ${data.decision}`));
947
+ tools_1.claudeProcessManager.handlePermissionResponse(data.promptId, data.decision, data.reason);
948
+ });
949
+ // Handle permission rules sync from mobile — write rules to disk for hook script
950
+ websocket_1.wsClient.on('permission_rules_sync', (data) => {
951
+ if (data.rules && Array.isArray(data.rules)) {
952
+ console.log(chalk_1.default.cyan(`[Claude] Permission rules sync: ${data.rules.length} rules`));
953
+ tools_1.claudeProcessManager.updatePermissionRules(data.rules);
954
+ }
955
+ });
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
+ // Handle Claude approval responses from mobile
964
+ websocket_1.wsClient.on('claude_approval_response', (data) => {
965
+ console.log(chalk_1.default.green(`[Claude] Approval response: ${data.approvalId} -> ${data.response}`));
966
+ tools_1.claudeProcessManager.handleApprovalResponse(data.approvalId, data.response);
967
+ });
968
+ // Handle user messages from mobile app (send to Claude session)
969
+ websocket_1.wsClient.on('user_message', async (data) => {
970
+ // SECURITY: Don't log message content - may contain sensitive prompts
971
+ console.log(chalk_1.default.cyan(`[Claude] User message received (${data.message.length} chars)`));
972
+ // The session should have been registered via claude_resume_session
973
+ // sendInput will spawn the process if needed using the registered session info
974
+ const terminalSessionId = data.sessionKey; // Use sessionKey as terminalSessionId
975
+ if (!terminalSessionId) {
976
+ console.log(chalk_1.default.yellow(`[Claude] No sessionKey provided in user_message`));
977
+ return;
978
+ }
979
+ // Check if this session has been taken over by the mobile user
980
+ if (!tools_1.claudeProcessManager.isTakenOver(terminalSessionId)) {
981
+ // Fresh session from auto-prompt (quick action): start new session with directory
982
+ if (data.directory) {
983
+ console.log(chalk_1.default.cyan(`[Claude] Starting fresh session for auto-prompt in ${data.directory}`));
984
+ const sent = await tools_1.claudeProcessManager.startAndSendMessage(data.directory, terminalSessionId, data.message, data.mode?.permissionMode === 'bypassPermissions', data.interactivePermissions);
985
+ if (sent) {
986
+ // Notify mobile that session is ready
987
+ websocket_1.wsClient.sendClaudeSessionUpdate({
988
+ sessionKey: terminalSessionId,
989
+ directory: data.directory,
990
+ state: 'active',
991
+ lastUsedAt: new Date().toISOString(),
992
+ });
993
+ }
994
+ else {
995
+ console.log(chalk_1.default.yellow(`[Claude] Failed to start fresh session`));
857
996
  }
997
+ return;
998
+ }
999
+ // Session not taken over — user must press "Take Over" first
1000
+ console.log(chalk_1.default.yellow(`[Claude] Session not taken over: ${terminalSessionId} — watch-only mode`));
1001
+ websocket_1.wsClient.sendClaudeSessionEvent({
1002
+ sessionKey: terminalSessionId,
1003
+ event: {
1004
+ type: 'error',
1005
+ message: 'You must take over this session before sending messages.',
1006
+ },
858
1007
  });
1008
+ return;
859
1009
  }
860
- else {
861
- console.log(chalk_1.default.yellow(`[RPC] Unknown method: ${data.method}`));
862
- websocket_1.wsClient.sendRpcResponse({
863
- requestId: data.requestId,
864
- error: { code: -32601, message: `Method not found: ${data.method}` }
1010
+ console.log(chalk_1.default.dim(`[Claude] Sending to session: ${terminalSessionId}`));
1011
+ const sent = await tools_1.claudeProcessManager.sendInput(terminalSessionId, data.message + '\n');
1012
+ if (!sent) {
1013
+ console.log(chalk_1.default.yellow(`[Claude] Failed to send message - session may need restart`));
1014
+ }
1015
+ });
1016
+ // Handle Claude process end
1017
+ tools_1.claudeProcessManager.on('session_ended', (data) => {
1018
+ console.log(chalk_1.default.dim(`[Claude] Session ended: ${data.terminalSessionId}`));
1019
+ websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'inactive');
1020
+ // Use terminalSessionId as fallback — fresh sessions (startAndSendMessage) may not
1021
+ // have captured the real session_id from SDK output before the process ended.
1022
+ // The mobile references these sessions by terminalSessionId, so the inactive update
1023
+ // must use it to prevent stale "active" sessions.
1024
+ const key = data.sessionKey || data.terminalSessionId;
1025
+ if (key) {
1026
+ websocket_1.wsClient.sendClaudeSessionUpdate({
1027
+ sessionKey: key,
1028
+ directory: data.directory,
1029
+ state: 'inactive',
1030
+ lastUsedAt: new Date().toISOString(),
865
1031
  });
866
1032
  }
867
- }
868
- catch (error) {
869
- console.error(chalk_1.default.red(`[RPC] Error handling ${data.method}:`, error.message));
870
- websocket_1.wsClient.sendRpcResponse({
871
- requestId: data.requestId,
872
- error: { code: -32603, message: error.message || 'Internal error' }
1033
+ });
1034
+ // Forward thinking content to mobile
1035
+ tools_1.claudeProcessManager.on('thinking_content', (data) => {
1036
+ if (data.content || !data.partial) {
1037
+ console.log(chalk_1.default.magenta(`[Claude] Thinking${data.partial ? ' (streaming)' : ' (complete)'}: ${data.content?.substring(0, 50) || '...'}`));
1038
+ }
1039
+ websocket_1.wsClient.sendThinkingContent({
1040
+ sessionKey: data.sessionKey,
1041
+ thinkingId: data.thinkingId,
1042
+ content: data.content,
1043
+ partial: data.partial,
873
1044
  });
874
- }
875
- });
876
- // Forward live transcript updates to WebSocket
877
- transcript_streamer_1.transcriptStreamer.on('update', (data) => {
878
- console.log(chalk_1.default.green(`[Transcript] Sending update for ${data.sessionKey}: ${data.entry?.type}`));
879
- websocket_1.wsClient.sendTranscriptUpdate(data);
880
- // Also update session lastUsedAt to keep it fresh
881
- // Find the session to get the directory
882
- const sessions = tools_1.claudeSessionDetector.getSessions();
883
- const session = sessions.find(s => s.sessionKey === data.sessionKey);
884
- if (session) {
885
- websocket_1.wsClient.sendClaudeSessionUpdate({
1045
+ });
1046
+ // Forward token usage to mobile
1047
+ tools_1.claudeProcessManager.on('token_usage', (data) => {
1048
+ console.log(chalk_1.default.blue(`[Claude] Tokens: ${data.usage.inputTokens} in / ${data.usage.outputTokens} out`));
1049
+ websocket_1.wsClient.sendTokenUsage({
886
1050
  sessionKey: data.sessionKey,
887
- directory: session.directory,
888
- state: 'active',
889
- lastUsedAt: new Date().toISOString(),
890
- transcriptPath: session.transcriptPath,
1051
+ usage: data.usage,
891
1052
  });
892
- }
893
- });
894
- // Forward Claude process output to WebSocket
895
- tools_1.claudeProcessManager.on('output', (data) => {
896
- websocket_1.wsClient.sendTerminalOutput(data);
897
- });
898
- // Forward Claude approval requests to WebSocket (mobile approval)
899
- tools_1.claudeProcessManager.on('claude_approval_request', (data) => {
900
- console.log(chalk_1.default.yellow(`[Claude] Approval request: ${data.approvalId}`));
901
- websocket_1.wsClient.sendClaudeApprovalRequest(data);
902
- });
903
- // Forward tool activity events to mobile (non-blocking notifications)
904
- tools_1.claudeProcessManager.on('tool_activity', (data) => {
905
- console.log(chalk_1.default.dim(`[Claude] Tool activity: ${data.toolName} - ${data.inputSummary?.substring(0, 60)}`));
906
- websocket_1.wsClient.sendToolActivity(data);
907
- });
908
- // Handle Claude approval responses from mobile
909
- websocket_1.wsClient.on('claude_approval_response', (data) => {
910
- console.log(chalk_1.default.green(`[Claude] Approval response: ${data.approvalId} -> ${data.response}`));
911
- tools_1.claudeProcessManager.handleApprovalResponse(data.approvalId, data.response);
912
- });
913
- // Handle user messages from mobile app (send to Claude session)
914
- websocket_1.wsClient.on('user_message', async (data) => {
915
- // SECURITY: Don't log message content - may contain sensitive prompts
916
- console.log(chalk_1.default.cyan(`[Claude] User message received (${data.message.length} chars)`));
917
- // The session should have been registered via claude_resume_session
918
- // sendInput will spawn the process if needed using the registered session info
919
- const terminalSessionId = data.sessionKey; // Use sessionKey as terminalSessionId
920
- if (!terminalSessionId) {
921
- console.log(chalk_1.default.yellow(`[Claude] No sessionKey provided in user_message`));
922
- return;
923
- }
924
- // Check if this session is registered (either active or registered for later spawn)
925
- if (!tools_1.claudeProcessManager.isClaudeSession(terminalSessionId)) {
926
- // Fresh session from auto-prompt (quick action): start new session with directory
927
- if (data.directory) {
928
- console.log(chalk_1.default.cyan(`[Claude] Starting fresh session for auto-prompt in ${data.directory}`));
929
- const sent = await tools_1.claudeProcessManager.startAndSendMessage(data.directory, terminalSessionId, data.message);
930
- if (sent) {
931
- // Notify mobile that session is ready
932
- websocket_1.wsClient.sendClaudeSessionUpdate({
933
- sessionKey: terminalSessionId,
934
- directory: data.directory,
935
- state: 'active',
936
- lastUsedAt: new Date().toISOString(),
937
- });
938
- }
939
- else {
940
- console.log(chalk_1.default.yellow(`[Claude] Failed to start fresh session`));
941
- }
942
- return;
1053
+ });
1054
+ // When a fresh session captures a real session_id, update the transcript
1055
+ // file watcher so the mobile gets real-time updates from the correct JSONL file.
1056
+ // This is critical for brainstorm/quick-action sessions where startAndSendMessage
1057
+ // creates a new Claude session with a new transcript file.
1058
+ tools_1.claudeProcessManager.on('session_id_captured', (data) => {
1059
+ const { terminalSessionId, sessionId } = data;
1060
+ console.log(chalk_1.default.green(`[Claude] New session_id captured for ${terminalSessionId}: ${sessionId}`));
1061
+ // Re-scan to pick up the new session's transcript path
1062
+ const sessions = tools_1.claudeSessionDetector.scanSessions();
1063
+ const newSession = sessions.find((s) => s.sessionKey === sessionId);
1064
+ if (newSession?.transcriptPath) {
1065
+ console.log(chalk_1.default.green(`[Claude] Updating transcript watcher → ${newSession.transcriptPath}`));
1066
+ transcript_streamer_1.transcriptStreamer.subscribeToUpdates(terminalSessionId, newSession.transcriptPath);
943
1067
  }
944
- console.log(chalk_1.default.yellow(`[Claude] Session not registered: ${terminalSessionId}`));
945
- console.log(chalk_1.default.dim(`[Claude] Hint: Mobile should send claude_resume_session first`));
946
- return;
947
- }
948
- console.log(chalk_1.default.dim(`[Claude] Sending to session: ${terminalSessionId}`));
949
- const sent = await tools_1.claudeProcessManager.sendInput(terminalSessionId, data.message + '\n');
950
- if (!sent) {
951
- console.log(chalk_1.default.yellow(`[Claude] Failed to send message - session may need restart`));
952
- }
953
- });
954
- // Handle Claude process end
955
- tools_1.claudeProcessManager.on('session_ended', (data) => {
956
- console.log(chalk_1.default.dim(`[Claude] Session ended: ${data.terminalSessionId}`));
957
- websocket_1.wsClient.sendToolStatusUpdate('claude_code', 'inactive');
958
- if (data.sessionKey) {
959
- websocket_1.wsClient.sendClaudeSessionUpdate({
1068
+ else {
1069
+ console.log(chalk_1.default.yellow(`[Claude] New session transcript not found yet, retrying in 1s...`));
1070
+ setTimeout(() => {
1071
+ const retrySessions = tools_1.claudeSessionDetector.scanSessions();
1072
+ const retrySession = retrySessions.find((s) => s.sessionKey === sessionId);
1073
+ if (retrySession?.transcriptPath) {
1074
+ console.log(chalk_1.default.green(`[Claude] Retry: updating transcript watcher → ${retrySession.transcriptPath}`));
1075
+ transcript_streamer_1.transcriptStreamer.subscribeToUpdates(terminalSessionId, retrySession.transcriptPath);
1076
+ }
1077
+ else {
1078
+ console.log(chalk_1.default.yellow(`[Claude] Retry: still not found for ${sessionId}`));
1079
+ }
1080
+ }, 1000);
1081
+ }
1082
+ });
1083
+ // Forward task progress to mobile
1084
+ tools_1.claudeProcessManager.on('task_progress', (data) => {
1085
+ if (data.type === 'list') {
1086
+ console.log(chalk_1.default.cyan(`[Claude] Task list: ${data.tasks?.length || 0} tasks`));
1087
+ }
1088
+ else {
1089
+ console.log(chalk_1.default.cyan(`[Claude] Task ${data.type}: ${data.task?.subject || data.task?.id}`));
1090
+ }
1091
+ websocket_1.wsClient.sendTaskProgress({
960
1092
  sessionKey: data.sessionKey,
961
- directory: data.directory,
962
- state: 'inactive',
963
- lastUsedAt: new Date().toISOString(),
1093
+ type: data.type,
1094
+ task: data.task,
1095
+ tasks: data.tasks,
964
1096
  });
965
- }
966
- });
967
- // Forward thinking content to mobile
968
- tools_1.claudeProcessManager.on('thinking_content', (data) => {
969
- if (data.content || !data.partial) {
970
- console.log(chalk_1.default.magenta(`[Claude] Thinking${data.partial ? ' (streaming)' : ' (complete)'}: ${data.content?.substring(0, 50) || '...'}`));
971
- }
972
- websocket_1.wsClient.sendThinkingContent({
973
- sessionKey: data.sessionKey,
974
- thinkingId: data.thinkingId,
975
- content: data.content,
976
- partial: data.partial,
977
1097
  });
978
- });
979
- // Forward token usage to mobile
980
- tools_1.claudeProcessManager.on('token_usage', (data) => {
981
- console.log(chalk_1.default.blue(`[Claude] Tokens: ${data.usage.inputTokens} in / ${data.usage.outputTokens} out`));
982
- websocket_1.wsClient.sendTokenUsage({
983
- sessionKey: data.sessionKey,
984
- usage: data.usage,
1098
+ 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
+ }
985
1103
  });
986
- });
987
- // Forward task progress to mobile
988
- tools_1.claudeProcessManager.on('task_progress', (data) => {
989
- if (data.type === 'list') {
990
- console.log(chalk_1.default.cyan(`[Claude] Task list: ${data.tasks?.length || 0} tasks`));
991
- }
992
- else {
993
- console.log(chalk_1.default.cyan(`[Claude] Task ${data.type}: ${data.task?.subject || data.task?.id}`));
994
- }
995
- websocket_1.wsClient.sendTaskProgress({
996
- sessionKey: data.sessionKey,
997
- type: data.type,
998
- task: data.task,
999
- tasks: data.tasks,
1104
+ websocket_1.wsClient.on('error', (error) => {
1105
+ console.error(chalk_1.default.red(`Connection error: ${error.message}`));
1000
1106
  });
1001
- });
1002
- websocket_1.wsClient.on('disconnected', (reason) => {
1003
- console.log(chalk_1.default.yellow(`\nDisconnected: ${reason}`));
1004
- if (reason !== 'io client disconnect') {
1005
- console.log(chalk_1.default.dim('Attempting to reconnect...'));
1006
- }
1007
- });
1008
- websocket_1.wsClient.on('error', (error) => {
1009
- console.error(chalk_1.default.red(`Connection error: ${error.message}`));
1010
- });
1011
- // Keep the process running
1012
- process.on('SIGINT', () => {
1013
- console.log(chalk_1.default.yellow('\nDisconnecting...'));
1014
- tools_1.claudeSessionDetector.stopWatching();
1015
- transcript_streamer_1.transcriptStreamer.cleanup();
1016
- websocket_1.wsClient.disconnect();
1017
- process.exit(0);
1018
- });
1019
- // Keep alive
1020
- await new Promise(() => { });
1021
- }
1022
- catch (error) {
1023
- spinner.fail('Failed to connect');
1024
- console.error(chalk_1.default.red(error.message || 'Unknown error'));
1107
+ // Keep the process running
1108
+ process.on('SIGINT', () => {
1109
+ console.log(chalk_1.default.yellow('\nDisconnecting...'));
1110
+ tools_1.claudeSessionDetector.stopWatching();
1111
+ transcript_streamer_1.transcriptStreamer.cleanup();
1112
+ websocket_1.wsClient.disconnect();
1113
+ process.exit(0);
1114
+ });
1115
+ // Keep alive
1116
+ await new Promise(() => { });
1117
+ }
1118
+ catch (error) {
1119
+ spinner.fail('Failed to connect');
1120
+ console.error(chalk_1.default.red(error.message || 'Unknown error'));
1121
+ }
1025
1122
  }
1026
- }
1027
- // Helper function to wait for pairing
1028
- async function waitForPairing(deviceId) {
1029
- return new Promise((resolve, reject) => {
1030
- const checkInterval = setInterval(async () => {
1031
- try {
1032
- const status = await api_1.api.checkPairingStatus(deviceId);
1033
- if (status.isPaired) {
1034
- clearInterval(checkInterval);
1035
- config_1.config.userId = status.userId;
1036
- config_1.config.pairedAt = new Date().toISOString();
1037
- console.log(chalk_1.default.green('\n✓ Device paired successfully!'));
1038
- console.log(chalk_1.default.dim('\nConnecting to receive commands...\n'));
1039
- // Auto-connect after successful pairing
1040
- resolve();
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
+ }
1041
1138
  }
1042
- }
1043
- catch (error) {
1044
- // Continue waiting
1045
- }
1046
- }, 2000);
1047
- // Handle Ctrl+C
1048
- process.on('SIGINT', () => {
1049
- clearInterval(checkInterval);
1050
- console.log(chalk_1.default.yellow('\nPairing cancelled.'));
1051
- process.exit(0);
1139
+ catch (error) {
1140
+ // Continue waiting
1141
+ }
1142
+ }, 2000);
1143
+ // Handle Ctrl+C
1144
+ process.on('SIGINT', () => {
1145
+ clearInterval(checkInterval);
1146
+ console.log(chalk_1.default.yellow('\nPairing cancelled.'));
1147
+ process.exit(0);
1148
+ });
1052
1149
  });
1150
+ }
1151
+ // Help command
1152
+ program
1153
+ .command('help')
1154
+ .description('Show available commands and usage')
1155
+ .action(() => { program.outputHelp(); });
1156
+ // Unknown command handling
1157
+ program.showHelpAfterError('Run "forkoff help" for available commands.');
1158
+ program.on('command:*', (operands) => {
1159
+ console.error(`Unknown command: ${operands[0]}\n`);
1160
+ console.log('Run "forkoff help" for available commands.');
1161
+ process.exitCode = 1;
1053
1162
  });
1163
+ return program;
1164
+ }
1165
+ // Run the CLI (skip when loaded as a module in tests)
1166
+ if (require.main === module) {
1167
+ createProgram().parse();
1054
1168
  }
1055
- // Run the CLI
1056
- program.parse();
1057
1169
  //# sourceMappingURL=index.js.map