@cmdctrl/cursor-ide 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter/cdp-client.d.ts +66 -0
- package/dist/adapter/cdp-client.d.ts.map +1 -0
- package/dist/adapter/cdp-client.js +304 -0
- package/dist/adapter/cdp-client.js.map +1 -0
- package/dist/adapter/cursor-db.d.ts +114 -0
- package/dist/adapter/cursor-db.d.ts.map +1 -0
- package/dist/adapter/cursor-db.js +438 -0
- package/dist/adapter/cursor-db.js.map +1 -0
- package/dist/client/messages.d.ts +98 -0
- package/dist/client/messages.d.ts.map +1 -0
- package/dist/client/messages.js +6 -0
- package/dist/client/messages.js.map +1 -0
- package/dist/client/websocket.d.ts +103 -0
- package/dist/client/websocket.d.ts.map +1 -0
- package/dist/client/websocket.js +428 -0
- package/dist/client/websocket.js.map +1 -0
- package/dist/commands/register.d.ts +10 -0
- package/dist/commands/register.d.ts.map +1 -0
- package/dist/commands/register.js +175 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/commands/start.d.ts +9 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +86 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +75 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/stop.d.ts +5 -0
- package/dist/commands/stop.d.ts.map +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/config/config.d.ts +68 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +189 -0
- package/dist/config/config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/session-discovery.d.ts +22 -0
- package/dist/session-discovery.d.ts.map +1 -0
- package/dist/session-discovery.js +90 -0
- package/dist/session-discovery.js.map +1 -0
- package/dist/session-watcher.d.ts +62 -0
- package/dist/session-watcher.d.ts.map +1 -0
- package/dist/session-watcher.js +210 -0
- package/dist/session-watcher.js.map +1 -0
- package/package.json +40 -0
- package/src/adapter/cdp-client.ts +296 -0
- package/src/adapter/cursor-db.ts +486 -0
- package/src/client/messages.ts +138 -0
- package/src/client/websocket.ts +486 -0
- package/src/commands/register.ts +201 -0
- package/src/commands/start.ts +106 -0
- package/src/commands/status.ts +83 -0
- package/src/commands/stop.ts +58 -0
- package/src/config/config.ts +167 -0
- package/src/index.ts +39 -0
- package/src/session-discovery.ts +115 -0
- package/src/session-watcher.ts +253 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import { CmdCtrlConfig, Credentials } from '../config/config';
|
|
4
|
+
import { getCDPClient } from '../adapter/cdp-client';
|
|
5
|
+
import { getCursorDB } from '../adapter/cursor-db';
|
|
6
|
+
import {
|
|
7
|
+
ServerMessage,
|
|
8
|
+
DaemonMessage,
|
|
9
|
+
TaskStartMessage,
|
|
10
|
+
TaskResumeMessage,
|
|
11
|
+
GetMessagesMessage,
|
|
12
|
+
WatchSessionMessage,
|
|
13
|
+
UnwatchSessionMessage,
|
|
14
|
+
SessionInfo,
|
|
15
|
+
} from './messages';
|
|
16
|
+
import { discoverSessions, ExternalSession } from '../session-discovery';
|
|
17
|
+
import { getSessionWatcher, SessionActivityEvent } from '../session-watcher';
|
|
18
|
+
|
|
19
|
+
const MAX_RECONNECT_DELAY = 30000; // 30 seconds
|
|
20
|
+
const INITIAL_RECONNECT_DELAY = 1000; // 1 second
|
|
21
|
+
const PING_INTERVAL = 30000; // 30 seconds
|
|
22
|
+
const SESSION_REFRESH_INTERVAL = 300000; // 5 minutes
|
|
23
|
+
|
|
24
|
+
export class DaemonClient {
|
|
25
|
+
private ws: WebSocket | null = null;
|
|
26
|
+
private config: CmdCtrlConfig;
|
|
27
|
+
private credentials: Credentials;
|
|
28
|
+
private reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
29
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
30
|
+
private pingTimer: NodeJS.Timeout | null = null;
|
|
31
|
+
private sessionRefreshTimer: NodeJS.Timeout | null = null;
|
|
32
|
+
private shouldReconnect = true;
|
|
33
|
+
private managedSessionIds: Set<string> = new Set();
|
|
34
|
+
private runningTasks: Set<string> = new Set();
|
|
35
|
+
|
|
36
|
+
constructor(config: CmdCtrlConfig, credentials: Credentials) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.credentials = credentials;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Handle session activity from the watcher and forward to server
|
|
43
|
+
*/
|
|
44
|
+
private handleSessionActivity(event: SessionActivityEvent): void {
|
|
45
|
+
console.log(`[SessionWatcher] Sending activity for session ${event.session_id}`);
|
|
46
|
+
this.send({
|
|
47
|
+
type: 'session_activity',
|
|
48
|
+
session_id: event.session_id,
|
|
49
|
+
file_path: event.file_path,
|
|
50
|
+
last_message: event.last_message,
|
|
51
|
+
message_count: event.message_count,
|
|
52
|
+
is_completion: event.is_completion,
|
|
53
|
+
user_message_uuid: event.user_message_uuid,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Connect to the CmdCtrl server via WebSocket
|
|
59
|
+
*/
|
|
60
|
+
async connect(): Promise<void> {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const serverUrl = new URL(this.config.serverUrl);
|
|
63
|
+
const wsProtocol = serverUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
64
|
+
const wsUrl = `${wsProtocol}//${serverUrl.host}/ws/daemon`;
|
|
65
|
+
|
|
66
|
+
console.log(`Connecting to ${wsUrl}...`);
|
|
67
|
+
|
|
68
|
+
this.ws = new WebSocket(wsUrl, {
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${this.credentials.refreshToken}`,
|
|
71
|
+
'X-Device-ID': this.config.deviceId,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.ws.on('open', () => {
|
|
76
|
+
console.log('WebSocket connected');
|
|
77
|
+
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
78
|
+
this.startPingInterval();
|
|
79
|
+
this.startSessionRefreshInterval();
|
|
80
|
+
this.startSessionWatcher();
|
|
81
|
+
this.sendStatus();
|
|
82
|
+
this.reportSessions();
|
|
83
|
+
resolve();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.ws.on('message', (data) => {
|
|
87
|
+
this.handleMessage(data.toString());
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this.ws.on('close', (code, reason) => {
|
|
91
|
+
console.log(`WebSocket closed: ${code} ${reason}`);
|
|
92
|
+
this.stopPingInterval();
|
|
93
|
+
this.stopSessionRefreshInterval();
|
|
94
|
+
this.stopSessionWatcher();
|
|
95
|
+
this.scheduleReconnect();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.ws.on('error', (err) => {
|
|
99
|
+
console.error('WebSocket error:', err.message);
|
|
100
|
+
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
101
|
+
reject(err);
|
|
102
|
+
}
|
|
103
|
+
// For established connections, the 'close' event usually follows 'error',
|
|
104
|
+
// but if the connection was killed abruptly, we may not get a clean close.
|
|
105
|
+
// Force close the socket to ensure 'close' event fires and triggers reconnect.
|
|
106
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
107
|
+
this.ws.terminate();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Disconnect from server
|
|
115
|
+
*/
|
|
116
|
+
async disconnect(): Promise<void> {
|
|
117
|
+
this.shouldReconnect = false;
|
|
118
|
+
|
|
119
|
+
if (this.reconnectTimer) {
|
|
120
|
+
clearTimeout(this.reconnectTimer);
|
|
121
|
+
this.reconnectTimer = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.stopPingInterval();
|
|
125
|
+
this.stopSessionRefreshInterval();
|
|
126
|
+
this.stopSessionWatcher();
|
|
127
|
+
|
|
128
|
+
// Disconnect CDP client
|
|
129
|
+
getCDPClient().disconnect();
|
|
130
|
+
|
|
131
|
+
// Close cursor DB
|
|
132
|
+
getCursorDB().close();
|
|
133
|
+
|
|
134
|
+
if (this.ws) {
|
|
135
|
+
this.ws.close(1000, 'Daemon shutting down');
|
|
136
|
+
this.ws = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Send a message to the server
|
|
142
|
+
*/
|
|
143
|
+
private send(message: DaemonMessage): void {
|
|
144
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
145
|
+
const json = JSON.stringify(message);
|
|
146
|
+
if (message.type !== 'pong') {
|
|
147
|
+
console.log(`[WS OUT] ${message.type}:`, json.length > 200 ? json.substring(0, 200) + '...' : json);
|
|
148
|
+
}
|
|
149
|
+
this.ws.send(json);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Send an event for a task
|
|
155
|
+
*/
|
|
156
|
+
private sendEvent(taskId: string, eventType: string, data: Record<string, unknown>): void {
|
|
157
|
+
this.send({
|
|
158
|
+
type: 'event',
|
|
159
|
+
task_id: taskId,
|
|
160
|
+
event_type: eventType,
|
|
161
|
+
...data,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Send current status to server
|
|
167
|
+
*/
|
|
168
|
+
private sendStatus(): void {
|
|
169
|
+
this.send({
|
|
170
|
+
type: 'status',
|
|
171
|
+
running_tasks: Array.from(this.runningTasks),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Handle incoming message from server
|
|
177
|
+
*/
|
|
178
|
+
private handleMessage(raw: string): void {
|
|
179
|
+
let msg: ServerMessage;
|
|
180
|
+
try {
|
|
181
|
+
msg = JSON.parse(raw) as ServerMessage;
|
|
182
|
+
} catch {
|
|
183
|
+
console.error('Failed to parse message:', raw);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (msg.type !== 'ping') {
|
|
188
|
+
console.log(`[WS IN] ${msg.type}:`, raw.length > 200 ? raw.substring(0, 200) + '...' : raw);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
switch (msg.type) {
|
|
192
|
+
case 'ping':
|
|
193
|
+
this.send({ type: 'pong' });
|
|
194
|
+
break;
|
|
195
|
+
|
|
196
|
+
case 'task_start':
|
|
197
|
+
this.handleTaskStart(msg as TaskStartMessage);
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
case 'task_resume':
|
|
201
|
+
this.handleTaskResume(msg as TaskResumeMessage);
|
|
202
|
+
break;
|
|
203
|
+
|
|
204
|
+
case 'task_cancel':
|
|
205
|
+
// For Cursor, we can't really cancel a running AI response
|
|
206
|
+
console.log(`Task cancel requested for ${msg.task_id} (not implemented for Cursor)`);
|
|
207
|
+
break;
|
|
208
|
+
|
|
209
|
+
case 'get_messages':
|
|
210
|
+
this.handleGetMessages(msg as GetMessagesMessage);
|
|
211
|
+
break;
|
|
212
|
+
|
|
213
|
+
case 'watch_session':
|
|
214
|
+
this.handleWatchSession(msg as WatchSessionMessage);
|
|
215
|
+
break;
|
|
216
|
+
|
|
217
|
+
case 'unwatch_session':
|
|
218
|
+
this.handleUnwatchSession(msg as UnwatchSessionMessage);
|
|
219
|
+
break;
|
|
220
|
+
|
|
221
|
+
default:
|
|
222
|
+
console.warn('Unknown message type:', (msg as { type: string }).type);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Handle task_start message - send message to Cursor via CDP
|
|
228
|
+
*/
|
|
229
|
+
private async handleTaskStart(msg: TaskStartMessage): Promise<void> {
|
|
230
|
+
console.log(`Starting task ${msg.task_id}: ${msg.instruction.substring(0, 50)}...`);
|
|
231
|
+
|
|
232
|
+
const cdp = getCDPClient();
|
|
233
|
+
|
|
234
|
+
// Check if CDP is available
|
|
235
|
+
const available = await cdp.isAvailable();
|
|
236
|
+
if (!available) {
|
|
237
|
+
console.error('CDP not available - Cursor not running with debug port?');
|
|
238
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
239
|
+
error: 'Cursor not available. Please start Cursor with: /Applications/Cursor.app/Contents/MacOS/Cursor --remote-debugging-port=9222',
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
// Connect to CDP
|
|
246
|
+
await cdp.connect();
|
|
247
|
+
|
|
248
|
+
// Check if composer is open
|
|
249
|
+
const composerOpen = await cdp.isComposerOpen();
|
|
250
|
+
if (!composerOpen) {
|
|
251
|
+
console.log('Opening composer panel...');
|
|
252
|
+
await cdp.toggleComposer();
|
|
253
|
+
// Wait a bit for panel to open
|
|
254
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Send the message
|
|
258
|
+
const success = await cdp.sendMessage(msg.instruction);
|
|
259
|
+
|
|
260
|
+
if (success) {
|
|
261
|
+
this.runningTasks.add(msg.task_id);
|
|
262
|
+
// For Cursor, we emit TASK_COMPLETE immediately since we can't track
|
|
263
|
+
// when the AI finishes responding. The session watcher will detect
|
|
264
|
+
// activity and send session_activity events.
|
|
265
|
+
this.sendEvent(msg.task_id, 'TASK_COMPLETE', {
|
|
266
|
+
result: 'Message sent to Cursor',
|
|
267
|
+
session_id: '', // We don't know the session ID yet
|
|
268
|
+
});
|
|
269
|
+
this.runningTasks.delete(msg.task_id);
|
|
270
|
+
} else {
|
|
271
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
272
|
+
error: 'Failed to send message to Cursor',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.error(`Failed to start task ${msg.task_id}:`, err);
|
|
277
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
278
|
+
error: (err as Error).message,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Handle task_resume message - send follow-up message to Cursor
|
|
285
|
+
*/
|
|
286
|
+
private async handleTaskResume(msg: TaskResumeMessage): Promise<void> {
|
|
287
|
+
console.log(`Resuming task ${msg.task_id} with message`);
|
|
288
|
+
|
|
289
|
+
const cdp = getCDPClient();
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
await cdp.connect();
|
|
293
|
+
const success = await cdp.sendMessage(msg.message);
|
|
294
|
+
|
|
295
|
+
if (success) {
|
|
296
|
+
this.sendEvent(msg.task_id, 'TASK_COMPLETE', {
|
|
297
|
+
result: 'Follow-up message sent to Cursor',
|
|
298
|
+
session_id: msg.session_id,
|
|
299
|
+
});
|
|
300
|
+
} else {
|
|
301
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
302
|
+
error: 'Failed to send follow-up message to Cursor',
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
} catch (err) {
|
|
306
|
+
console.error(`Failed to resume task ${msg.task_id}:`, err);
|
|
307
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
308
|
+
error: (err as Error).message,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Handle get_messages request - read from Cursor SQLite database
|
|
315
|
+
*/
|
|
316
|
+
private handleGetMessages(msg: GetMessagesMessage): void {
|
|
317
|
+
console.log(`Getting messages for session ${msg.session_id}, limit=${msg.limit}, after=${msg.after_uuid || 'none'}`);
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const cursorDb = getCursorDB();
|
|
321
|
+
const result = cursorDb.getMessages(msg.session_id, msg.limit, msg.before_uuid, msg.after_uuid);
|
|
322
|
+
|
|
323
|
+
this.send({
|
|
324
|
+
type: 'messages',
|
|
325
|
+
request_id: msg.request_id,
|
|
326
|
+
session_id: msg.session_id,
|
|
327
|
+
messages: result.messages,
|
|
328
|
+
has_more: result.hasMore,
|
|
329
|
+
oldest_uuid: result.oldestUuid,
|
|
330
|
+
newest_uuid: result.newestUuid,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
console.log(`Sent ${result.messages.length} messages, has_more=${result.hasMore}`);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
console.error(`Failed to get messages for session ${msg.session_id}:`, err);
|
|
336
|
+
|
|
337
|
+
this.send({
|
|
338
|
+
type: 'messages',
|
|
339
|
+
request_id: msg.request_id,
|
|
340
|
+
session_id: msg.session_id,
|
|
341
|
+
messages: [],
|
|
342
|
+
has_more: false,
|
|
343
|
+
error: (err as Error).message,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Handle watch_session request
|
|
350
|
+
*/
|
|
351
|
+
private handleWatchSession(msg: WatchSessionMessage): void {
|
|
352
|
+
console.log(`Starting to watch session ${msg.session_id}`);
|
|
353
|
+
const watcher = getSessionWatcher();
|
|
354
|
+
watcher.watchSession(msg.session_id);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Handle unwatch_session request
|
|
359
|
+
*/
|
|
360
|
+
private handleUnwatchSession(msg: UnwatchSessionMessage): void {
|
|
361
|
+
console.log(`Stopping watch for session ${msg.session_id}`);
|
|
362
|
+
const watcher = getSessionWatcher();
|
|
363
|
+
watcher.unwatchSession(msg.session_id);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Schedule reconnection with exponential backoff
|
|
368
|
+
*/
|
|
369
|
+
private scheduleReconnect(): void {
|
|
370
|
+
if (!this.shouldReconnect) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
console.log(`Reconnecting in ${this.reconnectDelay / 1000}s...`);
|
|
375
|
+
|
|
376
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
377
|
+
try {
|
|
378
|
+
await this.connect();
|
|
379
|
+
} catch {
|
|
380
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
381
|
+
this.scheduleReconnect();
|
|
382
|
+
}
|
|
383
|
+
}, this.reconnectDelay);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Start ping interval
|
|
388
|
+
*/
|
|
389
|
+
private startPingInterval(): void {
|
|
390
|
+
this.pingTimer = setInterval(() => {
|
|
391
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
392
|
+
this.ws.ping();
|
|
393
|
+
}
|
|
394
|
+
}, PING_INTERVAL);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Stop ping interval
|
|
399
|
+
*/
|
|
400
|
+
private stopPingInterval(): void {
|
|
401
|
+
if (this.pingTimer) {
|
|
402
|
+
clearInterval(this.pingTimer);
|
|
403
|
+
this.pingTimer = null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Start the session watcher
|
|
409
|
+
*/
|
|
410
|
+
private startSessionWatcher(): void {
|
|
411
|
+
const watcher = getSessionWatcher();
|
|
412
|
+
watcher.start(this.handleSessionActivity.bind(this));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Stop the session watcher
|
|
417
|
+
*/
|
|
418
|
+
private stopSessionWatcher(): void {
|
|
419
|
+
const watcher = getSessionWatcher();
|
|
420
|
+
watcher.stop();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Report discovered external sessions to server
|
|
425
|
+
*/
|
|
426
|
+
private reportSessions(): void {
|
|
427
|
+
try {
|
|
428
|
+
const sessions = discoverSessions(this.managedSessionIds);
|
|
429
|
+
|
|
430
|
+
const sessionInfos: SessionInfo[] = sessions.map((s: ExternalSession) => ({
|
|
431
|
+
session_id: s.session_id,
|
|
432
|
+
slug: s.slug,
|
|
433
|
+
title: s.title,
|
|
434
|
+
project: s.project,
|
|
435
|
+
project_name: s.project_name,
|
|
436
|
+
file_path: s.file_path,
|
|
437
|
+
last_message: s.last_message,
|
|
438
|
+
last_activity: s.last_activity,
|
|
439
|
+
is_active: s.is_active,
|
|
440
|
+
message_count: s.message_count,
|
|
441
|
+
}));
|
|
442
|
+
|
|
443
|
+
this.send({
|
|
444
|
+
type: 'report_sessions',
|
|
445
|
+
sessions: sessionInfos,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
console.log(`Reported ${sessionInfos.length} external sessions`);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
console.error('Failed to report sessions:', err);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Start periodic session refresh
|
|
456
|
+
*/
|
|
457
|
+
private startSessionRefreshInterval(): void {
|
|
458
|
+
this.sessionRefreshTimer = setInterval(() => {
|
|
459
|
+
this.reportSessions();
|
|
460
|
+
}, SESSION_REFRESH_INTERVAL);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Stop session refresh interval
|
|
465
|
+
*/
|
|
466
|
+
private stopSessionRefreshInterval(): void {
|
|
467
|
+
if (this.sessionRefreshTimer) {
|
|
468
|
+
clearInterval(this.sessionRefreshTimer);
|
|
469
|
+
this.sessionRefreshTimer = null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Add a session ID to the managed set
|
|
475
|
+
*/
|
|
476
|
+
addManagedSession(sessionId: string): void {
|
|
477
|
+
this.managedSessionIds.add(sessionId);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Remove a session ID from the managed set
|
|
482
|
+
*/
|
|
483
|
+
removeManagedSession(sessionId: string): void {
|
|
484
|
+
this.managedSessionIds.delete(sessionId);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import * as os from 'os';
|
|
2
|
+
import * as http from 'http';
|
|
3
|
+
import * as https from 'https';
|
|
4
|
+
import { URL } from 'url';
|
|
5
|
+
import {
|
|
6
|
+
writeConfig,
|
|
7
|
+
writeCredentials,
|
|
8
|
+
readConfig,
|
|
9
|
+
isRegistered,
|
|
10
|
+
CmdCtrlConfig,
|
|
11
|
+
Credentials,
|
|
12
|
+
} from '../config/config';
|
|
13
|
+
|
|
14
|
+
interface RegisterOptions {
|
|
15
|
+
server: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DeviceCodeResponse {
|
|
20
|
+
deviceCode: string;
|
|
21
|
+
userCode: string;
|
|
22
|
+
verificationUrl: string;
|
|
23
|
+
expiresIn: number;
|
|
24
|
+
interval: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface TokenResponse {
|
|
28
|
+
accessToken: string;
|
|
29
|
+
refreshToken: string;
|
|
30
|
+
expiresIn: number;
|
|
31
|
+
deviceId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Make an HTTP(S) request
|
|
36
|
+
*/
|
|
37
|
+
function request(
|
|
38
|
+
url: string,
|
|
39
|
+
method: string,
|
|
40
|
+
body?: object
|
|
41
|
+
): Promise<{ status: number; data: unknown }> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const parsed = new URL(url);
|
|
44
|
+
const client = parsed.protocol === 'https:' ? https : http;
|
|
45
|
+
|
|
46
|
+
const options = {
|
|
47
|
+
hostname: parsed.hostname,
|
|
48
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
49
|
+
path: parsed.pathname + parsed.search,
|
|
50
|
+
method,
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
Accept: 'application/json',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const req = client.request(options, (res) => {
|
|
58
|
+
let data = '';
|
|
59
|
+
res.on('data', (chunk) => (data += chunk));
|
|
60
|
+
res.on('end', () => {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = data ? JSON.parse(data) : {};
|
|
63
|
+
resolve({ status: res.statusCode || 0, data: parsed });
|
|
64
|
+
} catch {
|
|
65
|
+
resolve({ status: res.statusCode || 0, data: {} });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
req.on('error', reject);
|
|
71
|
+
|
|
72
|
+
if (body) {
|
|
73
|
+
req.write(JSON.stringify(body));
|
|
74
|
+
}
|
|
75
|
+
req.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Poll for token completion (after user verifies in browser)
|
|
81
|
+
*/
|
|
82
|
+
async function pollForToken(
|
|
83
|
+
serverUrl: string,
|
|
84
|
+
deviceCode: string,
|
|
85
|
+
interval: number,
|
|
86
|
+
expiresIn: number
|
|
87
|
+
): Promise<TokenResponse | null> {
|
|
88
|
+
const startTime = Date.now();
|
|
89
|
+
const expiresAt = startTime + expiresIn * 1000;
|
|
90
|
+
|
|
91
|
+
while (Date.now() < expiresAt) {
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const response = await request(`${serverUrl}/api/devices/token`, 'POST', {
|
|
96
|
+
deviceCode,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (response.status === 200) {
|
|
100
|
+
return response.data as TokenResponse;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const data = response.data as { error?: string };
|
|
104
|
+
if (response.status === 400 && data.error === 'authorization_pending') {
|
|
105
|
+
process.stdout.write('.');
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (response.status >= 400) {
|
|
110
|
+
console.error('\nError polling for token:', data);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error('\nError polling for token:', err);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.error('\nDevice code expired. Please try again.');
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Register command - implements GitHub CLI style device auth flow
|
|
125
|
+
*/
|
|
126
|
+
export async function register(options: RegisterOptions): Promise<void> {
|
|
127
|
+
const serverUrl = options.server.replace(/\/$/, '');
|
|
128
|
+
// Use hostname as device name - agent_type field distinguishes daemon types
|
|
129
|
+
const deviceName = options.name || os.hostname();
|
|
130
|
+
|
|
131
|
+
if (isRegistered()) {
|
|
132
|
+
const config = readConfig();
|
|
133
|
+
console.log(`Already registered as "${config?.deviceName}" (${config?.deviceId})`);
|
|
134
|
+
console.log(`Server: ${config?.serverUrl}`);
|
|
135
|
+
console.log('\nTo re-register, delete ~/.cmdctrl-cursor-ide/config.json and ~/.cmdctrl-cursor-ide/credentials');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(`Registering device "${deviceName}" with ${serverUrl}...\n`);
|
|
140
|
+
|
|
141
|
+
// Step 1: Request device code
|
|
142
|
+
let codeResponse: DeviceCodeResponse;
|
|
143
|
+
try {
|
|
144
|
+
const response = await request(`${serverUrl}/api/devices/code`, 'POST', {
|
|
145
|
+
deviceName,
|
|
146
|
+
hostname: os.hostname(),
|
|
147
|
+
agentType: 'cursor_ide', // Identify as Cursor IDE daemon
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (response.status !== 200) {
|
|
151
|
+
console.error('Failed to get device code:', response.data);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
codeResponse = response.data as DeviceCodeResponse;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error('Failed to connect to server:', err);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Step 2: Display instructions to user
|
|
162
|
+
console.log('To complete registration, open this URL in your browser:\n');
|
|
163
|
+
console.log(` ${codeResponse.verificationUrl}\n`);
|
|
164
|
+
console.log('Then enter this code:\n');
|
|
165
|
+
console.log(` ${codeResponse.userCode}\n`);
|
|
166
|
+
console.log('Waiting for verification...');
|
|
167
|
+
|
|
168
|
+
// Step 3: Poll for completion
|
|
169
|
+
const tokenResponse = await pollForToken(
|
|
170
|
+
serverUrl,
|
|
171
|
+
codeResponse.deviceCode,
|
|
172
|
+
codeResponse.interval,
|
|
173
|
+
codeResponse.expiresIn
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (!tokenResponse) {
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Step 4: Save config and credentials
|
|
181
|
+
const config: CmdCtrlConfig = {
|
|
182
|
+
serverUrl,
|
|
183
|
+
deviceId: tokenResponse.deviceId,
|
|
184
|
+
deviceName,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const credentials: Credentials = {
|
|
188
|
+
accessToken: tokenResponse.accessToken,
|
|
189
|
+
refreshToken: tokenResponse.refreshToken,
|
|
190
|
+
expiresAt: Date.now() + tokenResponse.expiresIn * 1000,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
writeConfig(config);
|
|
194
|
+
writeCredentials(credentials);
|
|
195
|
+
|
|
196
|
+
console.log('\n\nRegistration complete!');
|
|
197
|
+
console.log(`Device ID: ${tokenResponse.deviceId}`);
|
|
198
|
+
console.log(`\nRun 'cmdctrl-cursor-ide start' to connect to the server.`);
|
|
199
|
+
console.log(`\nIMPORTANT: Make sure to start Cursor with:`);
|
|
200
|
+
console.log(` /Applications/Cursor.app/Contents/MacOS/Cursor --remote-debugging-port=9222`);
|
|
201
|
+
}
|