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.
- package/README.md +7 -4
- package/dist/__tests__/cli-commands.test.d.ts +6 -0
- package/dist/__tests__/cli-commands.test.d.ts.map +1 -0
- package/dist/__tests__/cli-commands.test.js +213 -0
- package/dist/__tests__/cli-commands.test.js.map +1 -0
- package/dist/__tests__/startup.test.d.ts +11 -0
- package/dist/__tests__/startup.test.d.ts.map +1 -0
- package/dist/__tests__/startup.test.js +234 -0
- package/dist/__tests__/startup.test.js.map +1 -0
- package/dist/__tests__/tools/claude-process.test.js +221 -15
- package/dist/__tests__/tools/claude-process.test.js.map +1 -1
- package/dist/__tests__/tools/permission-hook.test.d.ts +17 -0
- package/dist/__tests__/tools/permission-hook.test.d.ts.map +1 -0
- package/dist/__tests__/tools/permission-hook.test.js +616 -0
- package/dist/__tests__/tools/permission-hook.test.js.map +1 -0
- package/dist/__tests__/tools/permission-ipc.test.d.ts +11 -0
- package/dist/__tests__/tools/permission-ipc.test.d.ts.map +1 -0
- package/dist/__tests__/tools/permission-ipc.test.js +612 -0
- package/dist/__tests__/tools/permission-ipc.test.js.map +1 -0
- package/dist/config.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1010 -898
- package/dist/index.js.map +1 -1
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +45 -15
- package/dist/startup.js.map +1 -1
- package/dist/tools/__tests__/claude-sessions.test.d.ts +2 -0
- package/dist/tools/__tests__/claude-sessions.test.d.ts.map +1 -0
- package/dist/tools/__tests__/claude-sessions.test.js +306 -0
- package/dist/tools/__tests__/claude-sessions.test.js.map +1 -0
- package/dist/tools/claude-process.d.ts +81 -4
- package/dist/tools/claude-process.d.ts.map +1 -1
- package/dist/tools/claude-process.js +332 -20
- package/dist/tools/claude-process.js.map +1 -1
- package/dist/tools/claude-sessions.d.ts +5 -0
- package/dist/tools/claude-sessions.d.ts.map +1 -1
- package/dist/tools/claude-sessions.js +16 -2
- package/dist/tools/claude-sessions.js.map +1 -1
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/permission-hook.d.ts +41 -0
- package/dist/tools/permission-hook.d.ts.map +1 -0
- package/dist/tools/permission-hook.js +312 -0
- package/dist/tools/permission-hook.js.map +1 -0
- package/dist/tools/permission-ipc.d.ts +109 -0
- package/dist/tools/permission-ipc.d.ts.map +1 -0
- package/dist/tools/permission-ipc.js +295 -0
- package/dist/tools/permission-ipc.js.map +1 -0
- package/dist/websocket.d.ts +14 -0
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +34 -4
- package/dist/websocket.js.map +1 -1
- package/jest.config.js +3 -0
- 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
|
-
|
|
56
|
-
program
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
(
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
173
|
+
catch (error) {
|
|
174
|
+
spinner.fail('Failed to register device');
|
|
175
|
+
console.error(chalk_1.default.red(error.message || 'Unknown error'));
|
|
136
176
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
232
|
+
// Non-critical
|
|
166
233
|
}
|
|
167
234
|
}
|
|
168
|
-
// Auto-connect after successful pairing
|
|
169
235
|
await startConnection();
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
console.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
274
|
-
console.
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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('
|
|
403
|
+
spinner.fail('Tool detection failed');
|
|
318
404
|
console.error(chalk_1.default.red(error.message));
|
|
319
405
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const spinner = (0, logger_1.createSpinner)('
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
spinner.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
418
|
-
spinner.
|
|
431
|
+
catch (toolError) {
|
|
432
|
+
spinner.warn('Tool detection skipped: ' + (toolError.message || 'unknown error'));
|
|
419
433
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
497
|
-
console.log(chalk_1.default.
|
|
498
|
-
|
|
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
|
-
//
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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.
|
|
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.
|
|
607
|
+
sessionKey: data.sessionKey,
|
|
551
608
|
event: { type: 'ready' },
|
|
552
609
|
});
|
|
553
|
-
console.log(chalk_1.default.green(`[Claude] Session
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
.
|
|
620
|
-
if (
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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:
|
|
717
|
+
fileName: path.basename(data.filePath),
|
|
718
|
+
error: error.message,
|
|
663
719
|
});
|
|
664
|
-
return;
|
|
665
720
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
762
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
806
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
830
|
-
|
|
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: {
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
888
|
-
state: 'active',
|
|
889
|
-
lastUsedAt: new Date().toISOString(),
|
|
890
|
-
transcriptPath: session.transcriptPath,
|
|
1051
|
+
usage: data.usage,
|
|
891
1052
|
});
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1044
|
-
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|