@cmdctrl/claude-code 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/claude-cli.d.ts +41 -0
- package/dist/adapter/claude-cli.d.ts.map +1 -0
- package/dist/adapter/claude-cli.js +525 -0
- package/dist/adapter/claude-cli.js.map +1 -0
- package/dist/adapter/events.d.ts +52 -0
- package/dist/adapter/events.d.ts.map +1 -0
- package/dist/adapter/events.js +134 -0
- package/dist/adapter/events.js.map +1 -0
- package/dist/client/messages.d.ts +140 -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 +115 -0
- package/dist/client/websocket.d.ts.map +1 -0
- package/dist/client/websocket.js +434 -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 +54 -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 +38 -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/commands/unregister.d.ts +5 -0
- package/dist/commands/unregister.d.ts.map +1 -0
- package/dist/commands/unregister.js +28 -0
- package/dist/commands/unregister.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 +193 -0
- package/dist/config/config.js.map +1 -0
- package/dist/handlers/context-handler.d.ts +37 -0
- package/dist/handlers/context-handler.d.ts.map +1 -0
- package/dist/handlers/context-handler.js +303 -0
- package/dist/handlers/context-handler.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/message-reader.d.ts +25 -0
- package/dist/message-reader.d.ts.map +1 -0
- package/dist/message-reader.js +454 -0
- package/dist/message-reader.js.map +1 -0
- package/dist/session-discovery.d.ts +48 -0
- package/dist/session-discovery.d.ts.map +1 -0
- package/dist/session-discovery.js +496 -0
- package/dist/session-discovery.js.map +1 -0
- package/dist/session-watcher.d.ts +92 -0
- package/dist/session-watcher.d.ts.map +1 -0
- package/dist/session-watcher.js +494 -0
- package/dist/session-watcher.js.map +1 -0
- package/dist/session-watcher.test.d.ts +9 -0
- package/dist/session-watcher.test.d.ts.map +1 -0
- package/dist/session-watcher.test.js +149 -0
- package/dist/session-watcher.test.js.map +1 -0
- package/jest.config.js +8 -0
- package/package.json +42 -0
- package/src/adapter/claude-cli.ts +591 -0
- package/src/adapter/events.ts +186 -0
- package/src/client/messages.ts +193 -0
- package/src/client/websocket.ts +509 -0
- package/src/commands/register.ts +201 -0
- package/src/commands/start.ts +70 -0
- package/src/commands/status.ts +47 -0
- package/src/commands/stop.ts +58 -0
- package/src/commands/unregister.ts +30 -0
- package/src/config/config.ts +163 -0
- package/src/handlers/context-handler.ts +337 -0
- package/src/index.ts +45 -0
- package/src/message-reader.ts +485 -0
- package/src/session-discovery.ts +557 -0
- package/src/session-watcher.test.ts +141 -0
- package/src/session-watcher.ts +560 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import { CmdCtrlConfig, Credentials, writeCredentials } from '../config/config';
|
|
4
|
+
import { ClaudeAdapter } from '../adapter/claude-cli';
|
|
5
|
+
import {
|
|
6
|
+
ServerMessage,
|
|
7
|
+
DaemonMessage,
|
|
8
|
+
TaskStartMessage,
|
|
9
|
+
TaskResumeMessage,
|
|
10
|
+
TaskCancelMessage,
|
|
11
|
+
GetMessagesMessage,
|
|
12
|
+
WatchSessionMessage,
|
|
13
|
+
UnwatchSessionMessage,
|
|
14
|
+
ContextRequestMessage,
|
|
15
|
+
SessionInfo,
|
|
16
|
+
} from './messages';
|
|
17
|
+
import { SessionEvent, CompletionEvent } from '../session-watcher';
|
|
18
|
+
import { discoverSessions, ExternalSession } from '../session-discovery';
|
|
19
|
+
import { readMessages, findSessionFile } from '../message-reader';
|
|
20
|
+
import { SessionWatcher } from '../session-watcher';
|
|
21
|
+
import { buildContextResponse } from '../handlers/context-handler';
|
|
22
|
+
import { SessionActivityMessage } from './messages';
|
|
23
|
+
|
|
24
|
+
const MAX_RECONNECT_DELAY = 30000; // 30 seconds
|
|
25
|
+
const INITIAL_RECONNECT_DELAY = 1000; // 1 second
|
|
26
|
+
const PING_INTERVAL = 30000; // 30 seconds
|
|
27
|
+
const SESSION_REFRESH_INTERVAL = 30000; // 30 seconds
|
|
28
|
+
|
|
29
|
+
export class DaemonClient {
|
|
30
|
+
private ws: WebSocket | null = null;
|
|
31
|
+
private config: CmdCtrlConfig;
|
|
32
|
+
private credentials: Credentials;
|
|
33
|
+
private reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
34
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
35
|
+
private pingTimer: NodeJS.Timeout | null = null;
|
|
36
|
+
private sessionRefreshTimer: NodeJS.Timeout | null = null;
|
|
37
|
+
private shouldReconnect = true;
|
|
38
|
+
private adapter: ClaudeAdapter;
|
|
39
|
+
private managedSessionIds: Set<string> = new Set(); // Sessions managed by this daemon
|
|
40
|
+
private lastReportedSessionCount = -1; // Track for change detection
|
|
41
|
+
private sessionWatcher: SessionWatcher;
|
|
42
|
+
|
|
43
|
+
constructor(config: CmdCtrlConfig, credentials: Credentials) {
|
|
44
|
+
this.config = config;
|
|
45
|
+
this.credentials = credentials;
|
|
46
|
+
this.adapter = new ClaudeAdapter(this.sendEvent.bind(this));
|
|
47
|
+
this.sessionWatcher = new SessionWatcher(
|
|
48
|
+
this.handleSessionEvent.bind(this),
|
|
49
|
+
this.handleSessionCompletion.bind(this)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Handle session events from the JSONL watcher and forward to server
|
|
55
|
+
* Converts SessionEvent to EventMessage format
|
|
56
|
+
*/
|
|
57
|
+
private handleSessionEvent(event: SessionEvent): void {
|
|
58
|
+
// Send as an event message to the server
|
|
59
|
+
this.send({
|
|
60
|
+
type: 'event',
|
|
61
|
+
task_id: '', // No task_id for watched session events (file-based, not daemon-spawned)
|
|
62
|
+
event_type: event.type,
|
|
63
|
+
session_id: event.sessionId,
|
|
64
|
+
uuid: event.uuid,
|
|
65
|
+
content: event.content,
|
|
66
|
+
timestamp: event.timestamp,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Handle session completion events from the JSONL watcher
|
|
72
|
+
* Sends session_activity message with is_completion=true to trigger push notifications
|
|
73
|
+
*/
|
|
74
|
+
private handleSessionCompletion(event: CompletionEvent): void {
|
|
75
|
+
console.log(`[WS] Sending session_activity completion for session ${event.sessionId.slice(-8)}`);
|
|
76
|
+
|
|
77
|
+
const message: SessionActivityMessage = {
|
|
78
|
+
type: 'session_activity',
|
|
79
|
+
session_id: event.sessionId,
|
|
80
|
+
file_path: event.filePath,
|
|
81
|
+
last_message: event.lastMessage,
|
|
82
|
+
message_count: event.messageCount,
|
|
83
|
+
is_completion: true,
|
|
84
|
+
last_activity: new Date().toISOString(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
this.send(message);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Connect to the CmdCtrl server via WebSocket
|
|
92
|
+
*/
|
|
93
|
+
async connect(): Promise<void> {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const serverUrl = new URL(this.config.serverUrl);
|
|
96
|
+
const wsProtocol = serverUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
97
|
+
const wsUrl = `${wsProtocol}//${serverUrl.host}/ws/daemon`;
|
|
98
|
+
|
|
99
|
+
console.log(`Connecting to ${wsUrl}...`);
|
|
100
|
+
|
|
101
|
+
this.ws = new WebSocket(wsUrl, {
|
|
102
|
+
headers: {
|
|
103
|
+
Authorization: `Bearer ${this.credentials.refreshToken}`,
|
|
104
|
+
'X-Device-ID': this.config.deviceId
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
this.ws.on('open', () => {
|
|
109
|
+
console.log('WebSocket connected');
|
|
110
|
+
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
111
|
+
this.startPingInterval();
|
|
112
|
+
this.startSessionRefreshInterval();
|
|
113
|
+
this.sendStatus();
|
|
114
|
+
this.reportSessions();
|
|
115
|
+
resolve();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
this.ws.on('message', (data) => {
|
|
119
|
+
this.handleMessage(data.toString());
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
this.ws.on('close', (code, reason) => {
|
|
123
|
+
console.log(`WebSocket closed: ${code} ${reason}`);
|
|
124
|
+
this.stopPingInterval();
|
|
125
|
+
this.stopSessionRefreshInterval();
|
|
126
|
+
this.scheduleReconnect();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
this.ws.on('error', (err) => {
|
|
130
|
+
console.error('WebSocket error:', err.message);
|
|
131
|
+
// Don't reject on error - let close handler deal with reconnection
|
|
132
|
+
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
133
|
+
reject(err);
|
|
134
|
+
}
|
|
135
|
+
// For established connections, the 'close' event usually follows 'error',
|
|
136
|
+
// but if the connection was killed abruptly, we may not get a clean close.
|
|
137
|
+
// Force close the socket to ensure 'close' event fires and triggers reconnect.
|
|
138
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
139
|
+
this.ws.terminate();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Disconnect from server
|
|
147
|
+
*/
|
|
148
|
+
async disconnect(): Promise<void> {
|
|
149
|
+
this.shouldReconnect = false;
|
|
150
|
+
|
|
151
|
+
if (this.reconnectTimer) {
|
|
152
|
+
clearTimeout(this.reconnectTimer);
|
|
153
|
+
this.reconnectTimer = null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.stopPingInterval();
|
|
157
|
+
this.stopSessionRefreshInterval();
|
|
158
|
+
|
|
159
|
+
// Stop all running tasks
|
|
160
|
+
await this.adapter.stopAll();
|
|
161
|
+
|
|
162
|
+
// Stop all session watchers
|
|
163
|
+
this.sessionWatcher.unwatchAll();
|
|
164
|
+
|
|
165
|
+
if (this.ws) {
|
|
166
|
+
this.ws.close(1000, 'Daemon shutting down');
|
|
167
|
+
this.ws = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Send a message to the server
|
|
173
|
+
*/
|
|
174
|
+
private send(message: DaemonMessage): void {
|
|
175
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
176
|
+
const json = JSON.stringify(message);
|
|
177
|
+
// Log all outgoing messages except pong (too noisy)
|
|
178
|
+
if (message.type !== 'pong') {
|
|
179
|
+
console.log(`[WS OUT] ${message.type}:`, json.length > 200 ? json.substring(0, 200) + '...' : json);
|
|
180
|
+
}
|
|
181
|
+
this.ws.send(json);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Send an event for a task
|
|
187
|
+
*/
|
|
188
|
+
private sendEvent(
|
|
189
|
+
taskId: string,
|
|
190
|
+
eventType: string,
|
|
191
|
+
data: Record<string, unknown>
|
|
192
|
+
): void {
|
|
193
|
+
// Auto-watch session file when we learn the session_id
|
|
194
|
+
// This enables unified notification path via session_activity
|
|
195
|
+
const sessionId = data.session_id as string | undefined;
|
|
196
|
+
if (sessionId) {
|
|
197
|
+
const filePath = findSessionFile(sessionId);
|
|
198
|
+
if (filePath) {
|
|
199
|
+
console.log(`[WS] Auto-watching session ${sessionId} for unified notifications`);
|
|
200
|
+
this.sessionWatcher.watchSession(sessionId, filePath);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.send({
|
|
205
|
+
type: 'event',
|
|
206
|
+
task_id: taskId,
|
|
207
|
+
event_type: eventType,
|
|
208
|
+
...data
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Send current status to server
|
|
214
|
+
*/
|
|
215
|
+
private sendStatus(): void {
|
|
216
|
+
this.send({
|
|
217
|
+
type: 'status',
|
|
218
|
+
running_tasks: this.adapter.getRunningTasks()
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Handle incoming message from server
|
|
224
|
+
*/
|
|
225
|
+
private handleMessage(raw: string): void {
|
|
226
|
+
let msg: ServerMessage;
|
|
227
|
+
try {
|
|
228
|
+
msg = JSON.parse(raw) as ServerMessage;
|
|
229
|
+
} catch {
|
|
230
|
+
console.error('Failed to parse message:', raw);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Log all incoming messages except ping (too noisy)
|
|
235
|
+
if (msg.type !== 'ping') {
|
|
236
|
+
console.log(`[WS IN] ${msg.type}:`, raw.length > 200 ? raw.substring(0, 200) + '...' : raw);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
switch (msg.type) {
|
|
240
|
+
case 'ping':
|
|
241
|
+
this.send({ type: 'pong' });
|
|
242
|
+
break;
|
|
243
|
+
|
|
244
|
+
case 'task_start':
|
|
245
|
+
this.handleTaskStart(msg as TaskStartMessage);
|
|
246
|
+
break;
|
|
247
|
+
|
|
248
|
+
case 'task_resume':
|
|
249
|
+
this.handleTaskResume(msg as TaskResumeMessage);
|
|
250
|
+
break;
|
|
251
|
+
|
|
252
|
+
case 'task_cancel':
|
|
253
|
+
this.handleTaskCancel(msg as TaskCancelMessage);
|
|
254
|
+
break;
|
|
255
|
+
|
|
256
|
+
case 'get_messages':
|
|
257
|
+
this.handleGetMessages(msg as GetMessagesMessage);
|
|
258
|
+
break;
|
|
259
|
+
|
|
260
|
+
case 'watch_session':
|
|
261
|
+
this.handleWatchSession(msg as WatchSessionMessage);
|
|
262
|
+
break;
|
|
263
|
+
|
|
264
|
+
case 'unwatch_session':
|
|
265
|
+
this.handleUnwatchSession(msg as UnwatchSessionMessage);
|
|
266
|
+
break;
|
|
267
|
+
|
|
268
|
+
case 'context_request':
|
|
269
|
+
this.handleContextRequest(msg as ContextRequestMessage);
|
|
270
|
+
break;
|
|
271
|
+
|
|
272
|
+
default:
|
|
273
|
+
console.warn('Unknown message type:', (msg as { type: string }).type);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Handle task_start message
|
|
279
|
+
*/
|
|
280
|
+
private async handleTaskStart(msg: TaskStartMessage): Promise<void> {
|
|
281
|
+
console.log(`Starting task ${msg.task_id}: ${msg.instruction.substring(0, 50)}...`);
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await this.adapter.startTask(msg.task_id, msg.instruction, msg.project_path);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.error(`Failed to start task ${msg.task_id}:`, err);
|
|
287
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
288
|
+
error: (err as Error).message
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Handle task_resume message
|
|
295
|
+
*/
|
|
296
|
+
private async handleTaskResume(msg: TaskResumeMessage): Promise<void> {
|
|
297
|
+
console.log(`Resuming task ${msg.task_id} with session ${msg.session_id}`);
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
await this.adapter.resumeTask(msg.task_id, msg.session_id, msg.message, msg.project_path);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
console.error(`Failed to resume task ${msg.task_id}:`, err);
|
|
303
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
304
|
+
error: (err as Error).message
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Handle task_cancel message
|
|
311
|
+
*/
|
|
312
|
+
private async handleTaskCancel(msg: TaskCancelMessage): Promise<void> {
|
|
313
|
+
console.log(`Cancelling task ${msg.task_id}`);
|
|
314
|
+
await this.adapter.cancelTask(msg.task_id);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Handle get_messages request
|
|
319
|
+
*/
|
|
320
|
+
private handleGetMessages(msg: GetMessagesMessage): void {
|
|
321
|
+
console.log(`Getting messages for session ${msg.session_id}, limit=${msg.limit}, before=${msg.before_uuid || 'none'}, after=${msg.after_uuid || 'none'}`);
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const result = readMessages(msg.session_id, msg.limit, msg.before_uuid, msg.after_uuid);
|
|
325
|
+
|
|
326
|
+
this.send({
|
|
327
|
+
type: 'messages',
|
|
328
|
+
request_id: msg.request_id,
|
|
329
|
+
session_id: msg.session_id,
|
|
330
|
+
messages: result.messages,
|
|
331
|
+
has_more: result.hasMore,
|
|
332
|
+
oldest_uuid: result.oldestUuid,
|
|
333
|
+
newest_uuid: result.newestUuid,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
console.log(`Sent ${result.messages.length} messages, has_more=${result.hasMore}`);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error(`Failed to get messages for session ${msg.session_id}:`, err);
|
|
339
|
+
|
|
340
|
+
this.send({
|
|
341
|
+
type: 'messages',
|
|
342
|
+
request_id: msg.request_id,
|
|
343
|
+
session_id: msg.session_id,
|
|
344
|
+
messages: [],
|
|
345
|
+
has_more: false,
|
|
346
|
+
error: (err as Error).message,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Handle watch_session request - start monitoring a session file for changes
|
|
353
|
+
*/
|
|
354
|
+
private handleWatchSession(msg: WatchSessionMessage): void {
|
|
355
|
+
console.log(`Starting to watch session ${msg.session_id} at ${msg.file_path}`);
|
|
356
|
+
this.sessionWatcher.watchSession(msg.session_id, msg.file_path);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Handle unwatch_session request - stop monitoring a session file
|
|
361
|
+
*/
|
|
362
|
+
private handleUnwatchSession(msg: UnwatchSessionMessage): void {
|
|
363
|
+
console.log(`Stopping watch for session ${msg.session_id}`);
|
|
364
|
+
this.sessionWatcher.unwatchSession(msg.session_id);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Handle context_request - extract session context for dashboard summaries
|
|
369
|
+
*/
|
|
370
|
+
private handleContextRequest(msg: ContextRequestMessage): void {
|
|
371
|
+
console.log(`Context request for session ${msg.session_id}`);
|
|
372
|
+
|
|
373
|
+
const response = buildContextResponse(msg.request_id, msg.session_id, {
|
|
374
|
+
includeInitialPrompt: msg.include.initial_prompt,
|
|
375
|
+
recentMessagesCount: msg.include.recent_messages,
|
|
376
|
+
includeLastToolUse: msg.include.last_tool_use,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
this.send(response);
|
|
380
|
+
|
|
381
|
+
if (response.error) {
|
|
382
|
+
console.log(`Context request failed: ${response.error}`);
|
|
383
|
+
} else {
|
|
384
|
+
console.log(`Sent context for session ${msg.session_id}: status=${response.context.status}, messages=${response.context.message_count}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Schedule reconnection with exponential backoff
|
|
390
|
+
*/
|
|
391
|
+
private scheduleReconnect(): void {
|
|
392
|
+
if (!this.shouldReconnect) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
console.log(`Reconnecting in ${this.reconnectDelay / 1000}s...`);
|
|
397
|
+
|
|
398
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
399
|
+
try {
|
|
400
|
+
await this.connect();
|
|
401
|
+
} catch {
|
|
402
|
+
// Increase delay with exponential backoff
|
|
403
|
+
this.reconnectDelay = Math.min(
|
|
404
|
+
this.reconnectDelay * 2,
|
|
405
|
+
MAX_RECONNECT_DELAY
|
|
406
|
+
);
|
|
407
|
+
this.scheduleReconnect();
|
|
408
|
+
}
|
|
409
|
+
}, this.reconnectDelay);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Start ping interval to keep connection alive
|
|
414
|
+
*/
|
|
415
|
+
private startPingInterval(): void {
|
|
416
|
+
this.pingTimer = setInterval(() => {
|
|
417
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
418
|
+
this.ws.ping();
|
|
419
|
+
}
|
|
420
|
+
}, PING_INTERVAL);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Stop ping interval
|
|
425
|
+
*/
|
|
426
|
+
private stopPingInterval(): void {
|
|
427
|
+
if (this.pingTimer) {
|
|
428
|
+
clearInterval(this.pingTimer);
|
|
429
|
+
this.pingTimer = null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Report discovered external sessions to server
|
|
435
|
+
*/
|
|
436
|
+
private async reportSessions(): Promise<void> {
|
|
437
|
+
try {
|
|
438
|
+
// Discover sessions, excluding any we're currently managing
|
|
439
|
+
const sessions = await discoverSessions(this.managedSessionIds);
|
|
440
|
+
|
|
441
|
+
// Convert to SessionInfo format
|
|
442
|
+
const sessionInfos: SessionInfo[] = sessions.map((s: ExternalSession) => ({
|
|
443
|
+
session_id: s.session_id,
|
|
444
|
+
slug: s.slug,
|
|
445
|
+
title: s.title,
|
|
446
|
+
project: s.project,
|
|
447
|
+
project_name: s.project_name,
|
|
448
|
+
file_path: s.file_path,
|
|
449
|
+
last_message: s.last_message,
|
|
450
|
+
last_activity: s.last_activity,
|
|
451
|
+
is_active: s.is_active,
|
|
452
|
+
message_count: s.message_count
|
|
453
|
+
}));
|
|
454
|
+
|
|
455
|
+
this.send({
|
|
456
|
+
type: 'report_sessions',
|
|
457
|
+
sessions: sessionInfos
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
console.log(`Reported ${sessionInfos.length} external sessions`);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
console.error('Failed to report sessions:', err);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Start periodic session refresh
|
|
468
|
+
*/
|
|
469
|
+
private startSessionRefreshInterval(): void {
|
|
470
|
+
this.sessionRefreshTimer = setInterval(() => {
|
|
471
|
+
this.reportSessions();
|
|
472
|
+
}, SESSION_REFRESH_INTERVAL);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Stop session refresh interval
|
|
477
|
+
*/
|
|
478
|
+
private stopSessionRefreshInterval(): void {
|
|
479
|
+
if (this.sessionRefreshTimer) {
|
|
480
|
+
clearInterval(this.sessionRefreshTimer);
|
|
481
|
+
this.sessionRefreshTimer = null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Add a session ID to the managed set (sessions started via this daemon)
|
|
487
|
+
*/
|
|
488
|
+
addManagedSession(sessionId: string): void {
|
|
489
|
+
this.managedSessionIds.add(sessionId);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Remove a session ID from the managed set
|
|
494
|
+
*/
|
|
495
|
+
removeManagedSession(sessionId: string): void {
|
|
496
|
+
this.managedSessionIds.delete(sessionId);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Refresh access token using refresh token
|
|
501
|
+
*/
|
|
502
|
+
async refreshToken(): Promise<boolean> {
|
|
503
|
+
// TODO: Implement token refresh
|
|
504
|
+
// POST to server with refresh token, get new access token
|
|
505
|
+
// Update credentials file
|
|
506
|
+
console.log('Token refresh not yet implemented');
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
@@ -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
|
+
// 400 with "authorization_pending" means keep polling
|
|
104
|
+
const data = response.data as { error?: string };
|
|
105
|
+
if (response.status === 400 && data.error === 'authorization_pending') {
|
|
106
|
+
process.stdout.write('.');
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Other errors should stop polling
|
|
111
|
+
if (response.status >= 400) {
|
|
112
|
+
console.error('\nError polling for token:', data);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error('\nError polling for token:', err);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.error('\nDevice code expired. Please try again.');
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Register command - implements GitHub CLI style device auth flow
|
|
127
|
+
*/
|
|
128
|
+
export async function register(options: RegisterOptions): Promise<void> {
|
|
129
|
+
const serverUrl = options.server.replace(/\/$/, ''); // Remove trailing slash
|
|
130
|
+
const deviceName = options.name || os.hostname();
|
|
131
|
+
|
|
132
|
+
// Check if already registered
|
|
133
|
+
if (isRegistered()) {
|
|
134
|
+
const config = readConfig();
|
|
135
|
+
console.log(`Already registered as "${config?.deviceName}" (${config?.deviceId})`);
|
|
136
|
+
console.log(`Server: ${config?.serverUrl}`);
|
|
137
|
+
console.log('\nTo re-register, delete ~/.cmdctrl-claude-code/config.json and ~/.cmdctrl-claude-code/credentials');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log(`Registering device "${deviceName}" with ${serverUrl}...\n`);
|
|
142
|
+
|
|
143
|
+
// Step 1: Request device code
|
|
144
|
+
let codeResponse: DeviceCodeResponse;
|
|
145
|
+
try {
|
|
146
|
+
const response = await request(`${serverUrl}/api/devices/code`, 'POST', {
|
|
147
|
+
deviceName,
|
|
148
|
+
hostname: os.hostname(),
|
|
149
|
+
agentType: 'claude_code',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (response.status !== 200) {
|
|
153
|
+
console.error('Failed to get device code:', response.data);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
codeResponse = response.data as DeviceCodeResponse;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error('Failed to connect to server:', err);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Step 2: Display instructions to user
|
|
164
|
+
console.log('To complete registration, open this URL in your browser:\n');
|
|
165
|
+
console.log(` ${codeResponse.verificationUrl}\n`);
|
|
166
|
+
console.log('Then enter this code:\n');
|
|
167
|
+
console.log(` ${codeResponse.userCode}\n`);
|
|
168
|
+
console.log('Waiting for verification...');
|
|
169
|
+
|
|
170
|
+
// Step 3: Poll for completion
|
|
171
|
+
const tokenResponse = await pollForToken(
|
|
172
|
+
serverUrl,
|
|
173
|
+
codeResponse.deviceCode,
|
|
174
|
+
codeResponse.interval,
|
|
175
|
+
codeResponse.expiresIn
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (!tokenResponse) {
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Step 4: Save config and credentials
|
|
183
|
+
const config: CmdCtrlConfig = {
|
|
184
|
+
serverUrl,
|
|
185
|
+
deviceId: tokenResponse.deviceId,
|
|
186
|
+
deviceName
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const credentials: Credentials = {
|
|
190
|
+
accessToken: tokenResponse.accessToken,
|
|
191
|
+
refreshToken: tokenResponse.refreshToken,
|
|
192
|
+
expiresAt: Date.now() + tokenResponse.expiresIn * 1000
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
writeConfig(config);
|
|
196
|
+
writeCredentials(credentials);
|
|
197
|
+
|
|
198
|
+
console.log('\n\nRegistration complete!');
|
|
199
|
+
console.log(`Device ID: ${tokenResponse.deviceId}`);
|
|
200
|
+
console.log(`\nRun 'cmdctrl-claude-code start' to connect to the server.`);
|
|
201
|
+
}
|