@cmdctrl/aider 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/agentapi.d.ts +100 -0
- package/dist/adapter/agentapi.d.ts.map +1 -0
- package/dist/adapter/agentapi.js +578 -0
- package/dist/adapter/agentapi.js.map +1 -0
- package/dist/client/messages.d.ts +89 -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 +66 -0
- package/dist/client/websocket.d.ts.map +1 -0
- package/dist/client/websocket.js +276 -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 +37 -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 +60 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +176 -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/package.json +42 -0
- package/src/adapter/agentapi.ts +656 -0
- package/src/client/messages.ts +125 -0
- package/src/client/websocket.ts +317 -0
- package/src/commands/register.ts +201 -0
- package/src/commands/start.ts +70 -0
- package/src/commands/status.ts +45 -0
- package/src/commands/stop.ts +58 -0
- package/src/config/config.ts +146 -0
- package/src/index.ts +39 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message types for daemon <-> server communication
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Server -> Daemon messages
|
|
6
|
+
|
|
7
|
+
export interface PingMessage {
|
|
8
|
+
type: 'ping';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TaskStartMessage {
|
|
12
|
+
type: 'task_start';
|
|
13
|
+
task_id: string;
|
|
14
|
+
instruction: string;
|
|
15
|
+
project_path?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TaskResumeMessage {
|
|
19
|
+
type: 'task_resume';
|
|
20
|
+
task_id: string;
|
|
21
|
+
session_id: string;
|
|
22
|
+
message: string;
|
|
23
|
+
project_path?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TaskCancelMessage {
|
|
27
|
+
type: 'task_cancel';
|
|
28
|
+
task_id: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GetMessagesMessage {
|
|
32
|
+
type: 'get_messages';
|
|
33
|
+
request_id: string;
|
|
34
|
+
session_id: string;
|
|
35
|
+
limit: number;
|
|
36
|
+
before_uuid?: string;
|
|
37
|
+
after_uuid?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Watch/unwatch are Claude Code specific (JSONL file watching)
|
|
41
|
+
// Aider ignores these but we define them for type safety
|
|
42
|
+
export interface WatchSessionMessage {
|
|
43
|
+
type: 'watch_session';
|
|
44
|
+
session_id: string;
|
|
45
|
+
file_path: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UnwatchSessionMessage {
|
|
49
|
+
type: 'unwatch_session';
|
|
50
|
+
session_id: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ServerMessage =
|
|
54
|
+
| PingMessage
|
|
55
|
+
| TaskStartMessage
|
|
56
|
+
| TaskResumeMessage
|
|
57
|
+
| TaskCancelMessage
|
|
58
|
+
| GetMessagesMessage
|
|
59
|
+
| WatchSessionMessage
|
|
60
|
+
| UnwatchSessionMessage;
|
|
61
|
+
|
|
62
|
+
// Daemon -> Server messages
|
|
63
|
+
|
|
64
|
+
export interface PongMessage {
|
|
65
|
+
type: 'pong';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StatusMessage {
|
|
69
|
+
type: 'status';
|
|
70
|
+
running_tasks: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface EventMessage {
|
|
74
|
+
type: 'event';
|
|
75
|
+
task_id: string;
|
|
76
|
+
event_type: string;
|
|
77
|
+
[key: string]: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SessionInfo {
|
|
81
|
+
session_id: string;
|
|
82
|
+
slug: string;
|
|
83
|
+
title: string;
|
|
84
|
+
project: string;
|
|
85
|
+
project_name: string;
|
|
86
|
+
file_path: string;
|
|
87
|
+
last_message: string;
|
|
88
|
+
last_activity: string;
|
|
89
|
+
is_active: boolean;
|
|
90
|
+
message_count: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ReportSessionsMessage {
|
|
94
|
+
type: 'report_sessions';
|
|
95
|
+
sessions: SessionInfo[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface MessageEntry {
|
|
99
|
+
uuid: string;
|
|
100
|
+
role: 'USER' | 'AGENT' | 'SYSTEM';
|
|
101
|
+
content: string;
|
|
102
|
+
timestamp: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface MessagesResponseMessage {
|
|
106
|
+
type: 'messages';
|
|
107
|
+
request_id: string;
|
|
108
|
+
session_id: string;
|
|
109
|
+
messages: MessageEntry[];
|
|
110
|
+
has_more: boolean;
|
|
111
|
+
oldest_uuid?: string;
|
|
112
|
+
newest_uuid?: string;
|
|
113
|
+
error?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type DaemonMessage = PongMessage | StatusMessage | EventMessage | ReportSessionsMessage | MessagesResponseMessage;
|
|
117
|
+
|
|
118
|
+
// Event types sent from daemon to server
|
|
119
|
+
export type EventType =
|
|
120
|
+
| 'WAIT_FOR_USER'
|
|
121
|
+
| 'TASK_COMPLETE'
|
|
122
|
+
| 'OUTPUT'
|
|
123
|
+
| 'PROGRESS'
|
|
124
|
+
| 'WARNING'
|
|
125
|
+
| 'ERROR';
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import { CmdCtrlConfig, Credentials, writeCredentials } from '../config/config';
|
|
4
|
+
import { AiderAdapter } from '../adapter/agentapi';
|
|
5
|
+
import {
|
|
6
|
+
ServerMessage,
|
|
7
|
+
DaemonMessage,
|
|
8
|
+
TaskStartMessage,
|
|
9
|
+
TaskResumeMessage,
|
|
10
|
+
TaskCancelMessage,
|
|
11
|
+
GetMessagesMessage
|
|
12
|
+
} from './messages';
|
|
13
|
+
|
|
14
|
+
const MAX_RECONNECT_DELAY = 30000; // 30 seconds
|
|
15
|
+
const INITIAL_RECONNECT_DELAY = 1000; // 1 second
|
|
16
|
+
const PING_INTERVAL = 30000; // 30 seconds
|
|
17
|
+
|
|
18
|
+
export class DaemonClient {
|
|
19
|
+
private ws: WebSocket | null = null;
|
|
20
|
+
private config: CmdCtrlConfig;
|
|
21
|
+
private credentials: Credentials;
|
|
22
|
+
private reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
23
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
24
|
+
private pingTimer: NodeJS.Timeout | null = null;
|
|
25
|
+
private shouldReconnect = true;
|
|
26
|
+
private adapter: AiderAdapter;
|
|
27
|
+
|
|
28
|
+
constructor(config: CmdCtrlConfig, credentials: Credentials) {
|
|
29
|
+
this.config = config;
|
|
30
|
+
this.credentials = credentials;
|
|
31
|
+
this.adapter = new AiderAdapter(this.sendEvent.bind(this));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Connect to the CmdCtrl server via WebSocket
|
|
36
|
+
*/
|
|
37
|
+
async connect(): Promise<void> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const serverUrl = new URL(this.config.serverUrl);
|
|
40
|
+
const wsProtocol = serverUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
41
|
+
const wsUrl = `${wsProtocol}//${serverUrl.host}/ws/daemon`;
|
|
42
|
+
|
|
43
|
+
console.log(`Connecting to ${wsUrl}...`);
|
|
44
|
+
|
|
45
|
+
this.ws = new WebSocket(wsUrl, {
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: `Bearer ${this.credentials.refreshToken}`,
|
|
48
|
+
'X-Device-ID': this.config.deviceId,
|
|
49
|
+
'X-Agent-Type': 'aider'
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.ws.on('open', () => {
|
|
54
|
+
console.log('WebSocket connected');
|
|
55
|
+
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
56
|
+
this.startPingInterval();
|
|
57
|
+
this.sendStatus();
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.ws.on('message', (data) => {
|
|
62
|
+
this.handleMessage(data.toString());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.ws.on('close', (code, reason) => {
|
|
66
|
+
console.log(`WebSocket closed: ${code} ${reason}`);
|
|
67
|
+
this.stopPingInterval();
|
|
68
|
+
this.scheduleReconnect();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
this.ws.on('error', (err) => {
|
|
72
|
+
console.error('WebSocket error:', err.message);
|
|
73
|
+
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
74
|
+
reject(err);
|
|
75
|
+
}
|
|
76
|
+
// For established connections, the 'close' event usually follows 'error',
|
|
77
|
+
// but if the connection was killed abruptly, we may not get a clean close.
|
|
78
|
+
// Force close the socket to ensure 'close' event fires and triggers reconnect.
|
|
79
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
80
|
+
this.ws.terminate();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Disconnect from server
|
|
88
|
+
*/
|
|
89
|
+
async disconnect(): Promise<void> {
|
|
90
|
+
this.shouldReconnect = false;
|
|
91
|
+
|
|
92
|
+
if (this.reconnectTimer) {
|
|
93
|
+
clearTimeout(this.reconnectTimer);
|
|
94
|
+
this.reconnectTimer = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.stopPingInterval();
|
|
98
|
+
|
|
99
|
+
// Stop all running tasks
|
|
100
|
+
await this.adapter.stopAll();
|
|
101
|
+
|
|
102
|
+
if (this.ws) {
|
|
103
|
+
this.ws.close(1000, 'Daemon shutting down');
|
|
104
|
+
this.ws = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Send a message to the server
|
|
110
|
+
*/
|
|
111
|
+
private send(message: DaemonMessage): void {
|
|
112
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
113
|
+
const json = JSON.stringify(message);
|
|
114
|
+
if (message.type !== 'pong') {
|
|
115
|
+
console.log(`[WS OUT] ${message.type}:`, json.length > 200 ? json.substring(0, 200) + '...' : json);
|
|
116
|
+
}
|
|
117
|
+
this.ws.send(json);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Send an event for a task
|
|
123
|
+
*/
|
|
124
|
+
private sendEvent(
|
|
125
|
+
taskId: string,
|
|
126
|
+
eventType: string,
|
|
127
|
+
data: Record<string, unknown>
|
|
128
|
+
): void {
|
|
129
|
+
this.send({
|
|
130
|
+
type: 'event',
|
|
131
|
+
task_id: taskId,
|
|
132
|
+
event_type: eventType,
|
|
133
|
+
...data
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Send current status to server
|
|
139
|
+
*/
|
|
140
|
+
private sendStatus(): void {
|
|
141
|
+
this.send({
|
|
142
|
+
type: 'status',
|
|
143
|
+
running_tasks: this.adapter.getRunningTasks()
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Handle incoming message from server
|
|
149
|
+
*/
|
|
150
|
+
private handleMessage(data: string): void {
|
|
151
|
+
try {
|
|
152
|
+
const msg = JSON.parse(data) as ServerMessage;
|
|
153
|
+
|
|
154
|
+
switch (msg.type) {
|
|
155
|
+
case 'ping':
|
|
156
|
+
this.send({ type: 'pong' });
|
|
157
|
+
break;
|
|
158
|
+
|
|
159
|
+
case 'task_start':
|
|
160
|
+
this.handleTaskStart(msg as TaskStartMessage);
|
|
161
|
+
break;
|
|
162
|
+
|
|
163
|
+
case 'task_resume':
|
|
164
|
+
this.handleTaskResume(msg as TaskResumeMessage);
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case 'task_cancel':
|
|
168
|
+
this.handleTaskCancel(msg as TaskCancelMessage);
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case 'get_messages':
|
|
172
|
+
this.handleGetMessages(msg as GetMessagesMessage);
|
|
173
|
+
break;
|
|
174
|
+
|
|
175
|
+
case 'watch_session':
|
|
176
|
+
case 'unwatch_session':
|
|
177
|
+
// Aider doesn't support file watching (Claude Code specific)
|
|
178
|
+
// Silently ignore these messages
|
|
179
|
+
break;
|
|
180
|
+
|
|
181
|
+
default:
|
|
182
|
+
console.log(`Unknown message type: ${(msg as { type: string }).type}`);
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error('Failed to parse message:', err);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Handle task_start message
|
|
191
|
+
*/
|
|
192
|
+
private async handleTaskStart(msg: TaskStartMessage): Promise<void> {
|
|
193
|
+
console.log(`Received task_start: ${msg.task_id}`);
|
|
194
|
+
try {
|
|
195
|
+
await this.adapter.startTask(msg.task_id, msg.instruction, msg.project_path);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error(`Failed to start task ${msg.task_id}:`, err);
|
|
198
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
199
|
+
error: (err as Error).message
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Handle task_resume message
|
|
206
|
+
*/
|
|
207
|
+
private async handleTaskResume(msg: TaskResumeMessage): Promise<void> {
|
|
208
|
+
console.log(`Received task_resume: ${msg.task_id}`);
|
|
209
|
+
try {
|
|
210
|
+
await this.adapter.resumeTask(msg.task_id, msg.session_id, msg.message, msg.project_path);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.error(`Failed to resume task ${msg.task_id}:`, err);
|
|
213
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
214
|
+
error: (err as Error).message
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Handle task_cancel message
|
|
221
|
+
*/
|
|
222
|
+
private async handleTaskCancel(msg: TaskCancelMessage): Promise<void> {
|
|
223
|
+
console.log(`Received task_cancel: ${msg.task_id}`);
|
|
224
|
+
await this.adapter.cancelTask(msg.task_id);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Handle get_messages request
|
|
229
|
+
* Fetches messages from AgentAPI's /messages endpoint
|
|
230
|
+
*/
|
|
231
|
+
private async handleGetMessages(msg: GetMessagesMessage): Promise<void> {
|
|
232
|
+
console.log(`Received get_messages for session: ${msg.session_id}, after=${msg.after_uuid || 'none'}`);
|
|
233
|
+
|
|
234
|
+
// The session_id is the canonical ID like "dev-xxx:aider:PENDING-xxx"
|
|
235
|
+
// We need to find the task_id which matches this session
|
|
236
|
+
const taskId = msg.session_id;
|
|
237
|
+
|
|
238
|
+
const messages = await this.adapter.getMessages(taskId);
|
|
239
|
+
|
|
240
|
+
if (messages === null) {
|
|
241
|
+
// AgentAPI not running for this session
|
|
242
|
+
this.send({
|
|
243
|
+
type: 'messages',
|
|
244
|
+
request_id: msg.request_id,
|
|
245
|
+
session_id: msg.session_id,
|
|
246
|
+
messages: [],
|
|
247
|
+
has_more: false,
|
|
248
|
+
error: 'Session not active - AgentAPI not running'
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Convert AgentAPI messages to our format
|
|
254
|
+
let formattedMessages = messages.map(m => ({
|
|
255
|
+
uuid: `aider-msg-${m.id}`,
|
|
256
|
+
role: m.role === 'agent' ? 'AGENT' as const : 'USER' as const,
|
|
257
|
+
content: m.content,
|
|
258
|
+
timestamp: m.time
|
|
259
|
+
}));
|
|
260
|
+
|
|
261
|
+
// Handle after_uuid for incremental fetches
|
|
262
|
+
if (msg.after_uuid) {
|
|
263
|
+
const afterIdx = formattedMessages.findIndex(m => m.uuid === msg.after_uuid);
|
|
264
|
+
if (afterIdx !== -1) {
|
|
265
|
+
formattedMessages = formattedMessages.slice(afterIdx + 1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this.send({
|
|
270
|
+
type: 'messages',
|
|
271
|
+
request_id: msg.request_id,
|
|
272
|
+
session_id: msg.session_id,
|
|
273
|
+
messages: formattedMessages,
|
|
274
|
+
has_more: false,
|
|
275
|
+
newest_uuid: formattedMessages.length > 0 ? formattedMessages[formattedMessages.length - 1].uuid : undefined,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Schedule reconnection with exponential backoff
|
|
281
|
+
*/
|
|
282
|
+
private scheduleReconnect(): void {
|
|
283
|
+
if (!this.shouldReconnect) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log(`Reconnecting in ${this.reconnectDelay}ms...`);
|
|
288
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
289
|
+
try {
|
|
290
|
+
await this.connect();
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error('Reconnection failed:', err);
|
|
293
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
294
|
+
this.scheduleReconnect();
|
|
295
|
+
}
|
|
296
|
+
}, this.reconnectDelay);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Start ping interval to keep connection alive
|
|
301
|
+
*/
|
|
302
|
+
private startPingInterval(): void {
|
|
303
|
+
this.pingTimer = setInterval(() => {
|
|
304
|
+
this.sendStatus();
|
|
305
|
+
}, PING_INTERVAL);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Stop ping interval
|
|
310
|
+
*/
|
|
311
|
+
private stopPingInterval(): void {
|
|
312
|
+
if (this.pingTimer) {
|
|
313
|
+
clearInterval(this.pingTimer);
|
|
314
|
+
this.pingTimer = null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -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(/\/$/, '');
|
|
130
|
+
const deviceName = options.name || `${os.hostname()}-aider`;
|
|
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-aider/config.json and ~/.cmdctrl-aider/credentials');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log(`Registering Aider 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: 'aider'
|
|
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-aider start' to connect to the server.`);
|
|
201
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readConfig,
|
|
3
|
+
readCredentials,
|
|
4
|
+
isRegistered,
|
|
5
|
+
writePidFile,
|
|
6
|
+
isDaemonRunning
|
|
7
|
+
} from '../config/config';
|
|
8
|
+
import { DaemonClient } from '../client/websocket';
|
|
9
|
+
|
|
10
|
+
interface StartOptions {
|
|
11
|
+
foreground?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Start command - launch the daemon and connect to server
|
|
16
|
+
*/
|
|
17
|
+
export async function start(options: StartOptions): Promise<void> {
|
|
18
|
+
// Check registration
|
|
19
|
+
if (!isRegistered()) {
|
|
20
|
+
console.error('Device not registered. Run "cmdctrl-aider register" first.');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check if already running
|
|
25
|
+
if (isDaemonRunning()) {
|
|
26
|
+
console.error('Daemon is already running. Run "cmdctrl-aider stop" first.');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const config = readConfig()!;
|
|
31
|
+
const credentials = readCredentials()!;
|
|
32
|
+
|
|
33
|
+
console.log(`Starting CmdCtrl Aider daemon...`);
|
|
34
|
+
console.log(`Server: ${config.serverUrl}`);
|
|
35
|
+
console.log(`Device: ${config.deviceName} (${config.deviceId})`);
|
|
36
|
+
|
|
37
|
+
// Write PID file
|
|
38
|
+
writePidFile(process.pid);
|
|
39
|
+
|
|
40
|
+
// Create and start client
|
|
41
|
+
const client = new DaemonClient(config, credentials);
|
|
42
|
+
|
|
43
|
+
// Handle shutdown signals
|
|
44
|
+
const shutdown = async () => {
|
|
45
|
+
console.log('\nShutting down...');
|
|
46
|
+
await client.disconnect();
|
|
47
|
+
process.exit(0);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
process.on('SIGINT', shutdown);
|
|
51
|
+
process.on('SIGTERM', shutdown);
|
|
52
|
+
|
|
53
|
+
// Connect and run
|
|
54
|
+
try {
|
|
55
|
+
await client.connect();
|
|
56
|
+
console.log('Connected to server.');
|
|
57
|
+
|
|
58
|
+
if (options.foreground) {
|
|
59
|
+
console.log('Running in foreground. Press Ctrl+C to stop.\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Keep process alive - the WebSocket client handles events
|
|
63
|
+
await new Promise(() => {
|
|
64
|
+
// Never resolves - daemon runs until killed
|
|
65
|
+
});
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error('Failed to start daemon:', err);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|