@agent-relay/daemon 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.
Files changed (109) hide show
  1. package/dist/agent-manager.d.ts +134 -0
  2. package/dist/agent-manager.d.ts.map +1 -0
  3. package/dist/agent-manager.js +578 -0
  4. package/dist/agent-manager.js.map +1 -0
  5. package/dist/agent-registry.d.ts +99 -0
  6. package/dist/agent-registry.d.ts.map +1 -0
  7. package/dist/agent-registry.js +213 -0
  8. package/dist/agent-registry.js.map +1 -0
  9. package/dist/agent-signing.d.ts +158 -0
  10. package/dist/agent-signing.d.ts.map +1 -0
  11. package/dist/agent-signing.js +523 -0
  12. package/dist/agent-signing.js.map +1 -0
  13. package/dist/api.d.ts +106 -0
  14. package/dist/api.d.ts.map +1 -0
  15. package/dist/api.js +876 -0
  16. package/dist/api.js.map +1 -0
  17. package/dist/auth.d.ts +94 -0
  18. package/dist/auth.d.ts.map +1 -0
  19. package/dist/auth.js +197 -0
  20. package/dist/auth.js.map +1 -0
  21. package/dist/channel-membership-store.d.ts +55 -0
  22. package/dist/channel-membership-store.d.ts.map +1 -0
  23. package/dist/channel-membership-store.js +176 -0
  24. package/dist/channel-membership-store.js.map +1 -0
  25. package/dist/cli-auth.d.ts +89 -0
  26. package/dist/cli-auth.d.ts.map +1 -0
  27. package/dist/cli-auth.js +792 -0
  28. package/dist/cli-auth.js.map +1 -0
  29. package/dist/cloud-sync.d.ts +150 -0
  30. package/dist/cloud-sync.d.ts.map +1 -0
  31. package/dist/cloud-sync.js +446 -0
  32. package/dist/cloud-sync.js.map +1 -0
  33. package/dist/connection.d.ts +130 -0
  34. package/dist/connection.d.ts.map +1 -0
  35. package/dist/connection.js +438 -0
  36. package/dist/connection.js.map +1 -0
  37. package/dist/consensus-integration.d.ts +167 -0
  38. package/dist/consensus-integration.d.ts.map +1 -0
  39. package/dist/consensus-integration.js +371 -0
  40. package/dist/consensus-integration.js.map +1 -0
  41. package/dist/consensus.d.ts +271 -0
  42. package/dist/consensus.d.ts.map +1 -0
  43. package/dist/consensus.js +632 -0
  44. package/dist/consensus.js.map +1 -0
  45. package/dist/delivery-tracker.d.ts +34 -0
  46. package/dist/delivery-tracker.d.ts.map +1 -0
  47. package/dist/delivery-tracker.js +104 -0
  48. package/dist/delivery-tracker.js.map +1 -0
  49. package/dist/enhanced-features.d.ts +118 -0
  50. package/dist/enhanced-features.d.ts.map +1 -0
  51. package/dist/enhanced-features.js +176 -0
  52. package/dist/enhanced-features.js.map +1 -0
  53. package/dist/index.d.ts +31 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +37 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/migrations/index.d.ts +73 -0
  58. package/dist/migrations/index.d.ts.map +1 -0
  59. package/dist/migrations/index.js +241 -0
  60. package/dist/migrations/index.js.map +1 -0
  61. package/dist/orchestrator.d.ts +217 -0
  62. package/dist/orchestrator.d.ts.map +1 -0
  63. package/dist/orchestrator.js +1143 -0
  64. package/dist/orchestrator.js.map +1 -0
  65. package/dist/rate-limiter.d.ts +68 -0
  66. package/dist/rate-limiter.d.ts.map +1 -0
  67. package/dist/rate-limiter.js +130 -0
  68. package/dist/rate-limiter.js.map +1 -0
  69. package/dist/registry.d.ts +9 -0
  70. package/dist/registry.d.ts.map +1 -0
  71. package/dist/registry.js +9 -0
  72. package/dist/registry.js.map +1 -0
  73. package/dist/relay-ledger.d.ts +261 -0
  74. package/dist/relay-ledger.d.ts.map +1 -0
  75. package/dist/relay-ledger.js +532 -0
  76. package/dist/relay-ledger.js.map +1 -0
  77. package/dist/relay-watchdog.d.ts +125 -0
  78. package/dist/relay-watchdog.d.ts.map +1 -0
  79. package/dist/relay-watchdog.js +611 -0
  80. package/dist/relay-watchdog.js.map +1 -0
  81. package/dist/repo-manager.d.ts +116 -0
  82. package/dist/repo-manager.d.ts.map +1 -0
  83. package/dist/repo-manager.js +384 -0
  84. package/dist/repo-manager.js.map +1 -0
  85. package/dist/router.d.ts +370 -0
  86. package/dist/router.d.ts.map +1 -0
  87. package/dist/router.js +1437 -0
  88. package/dist/router.js.map +1 -0
  89. package/dist/server.d.ts +174 -0
  90. package/dist/server.d.ts.map +1 -0
  91. package/dist/server.js +1001 -0
  92. package/dist/server.js.map +1 -0
  93. package/dist/spawn-manager.d.ts +78 -0
  94. package/dist/spawn-manager.d.ts.map +1 -0
  95. package/dist/spawn-manager.js +165 -0
  96. package/dist/spawn-manager.js.map +1 -0
  97. package/dist/sync-queue.d.ts +116 -0
  98. package/dist/sync-queue.d.ts.map +1 -0
  99. package/dist/sync-queue.js +361 -0
  100. package/dist/sync-queue.js.map +1 -0
  101. package/dist/types.d.ts +133 -0
  102. package/dist/types.d.ts.map +1 -0
  103. package/dist/types.js +6 -0
  104. package/dist/types.js.map +1 -0
  105. package/dist/workspace-manager.d.ts +80 -0
  106. package/dist/workspace-manager.d.ts.map +1 -0
  107. package/dist/workspace-manager.js +314 -0
  108. package/dist/workspace-manager.js.map +1 -0
  109. package/package.json +52 -0
package/dist/api.js ADDED
@@ -0,0 +1,876 @@
1
+ /**
2
+ * Daemon API
3
+ * REST and WebSocket API for dashboard communication.
4
+ */
5
+ import * as http from 'http';
6
+ import WebSocket, { WebSocketServer } from 'ws';
7
+ import { EventEmitter } from 'events';
8
+ import { createLogger, metrics } from '@agent-relay/resiliency';
9
+ import { getWorkspaceManager } from './workspace-manager.js';
10
+ import { getAgentManager } from './agent-manager.js';
11
+ import { startCLIAuth, getAuthSession, submitAuthCode, cancelAuthSession, getSupportedProviders, } from './cli-auth.js';
12
+ import { getRepoManager, initRepoManager } from './repo-manager.js';
13
+ const logger = createLogger('daemon-api');
14
+ export class DaemonApi extends EventEmitter {
15
+ server;
16
+ wss;
17
+ workspaceManager;
18
+ agentManager;
19
+ sessions = new Map();
20
+ routes = new Map();
21
+ config;
22
+ allowedOrigins;
23
+ allowAllOrigins;
24
+ // Track output subscriptions per agent/client
25
+ outputSubscribers = new Map();
26
+ wsSubscriptions = new WeakMap();
27
+ // Track alive status for ping/pong keepalive
28
+ clientAlive = new WeakMap();
29
+ pingInterval;
30
+ constructor(config) {
31
+ super();
32
+ this.config = config;
33
+ this.workspaceManager = getWorkspaceManager(config.dataDir);
34
+ this.agentManager = getAgentManager(config.dataDir);
35
+ const configuredOrigins = this.loadAllowedOrigins(config);
36
+ this.allowAllOrigins = configuredOrigins.includes('*');
37
+ this.allowedOrigins = new Set(configuredOrigins
38
+ .map(origin => origin.trim())
39
+ .filter(origin => origin && origin !== '*'));
40
+ // Setup routes
41
+ this.setupRoutes();
42
+ // Forward events to WebSocket clients
43
+ this.workspaceManager.on('event', (event) => this.broadcastEvent(event));
44
+ this.agentManager.on('event', (event) => this.broadcastEvent(event));
45
+ }
46
+ /**
47
+ * Resolve allowed origins from config/env (comma-separated list).
48
+ * Empty list means no cross-origin access is permitted.
49
+ */
50
+ loadAllowedOrigins(config) {
51
+ if (config.allowedOrigins?.length) {
52
+ return config.allowedOrigins;
53
+ }
54
+ const envOrigins = process.env.AGENT_RELAY_API_ALLOWED_ORIGINS;
55
+ if (envOrigins?.trim()) {
56
+ return envOrigins.split(',').map(origin => origin.trim()).filter(Boolean);
57
+ }
58
+ return [];
59
+ }
60
+ /**
61
+ * Return allowed origin for CORS or null if explicitly blocked.
62
+ * Undefined means no CORS header will be set (same-origin/server-to-server).
63
+ */
64
+ resolveAllowedOrigin(originHeader, requestHost) {
65
+ if (!originHeader)
66
+ return undefined; // Non-browser or requests without Origin header
67
+ if (this.allowAllOrigins)
68
+ return originHeader;
69
+ if (this.allowedOrigins.has(originHeader))
70
+ return originHeader;
71
+ if (requestHost) {
72
+ try {
73
+ const originHost = new URL(originHeader).host;
74
+ // Allow same-origin requests even if not explicitly configured
75
+ if (originHost === requestHost) {
76
+ return originHeader;
77
+ }
78
+ }
79
+ catch {
80
+ // Malformed origin; treat as blocked below
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+ /**
86
+ * Track an output subscription for a client/agent pair
87
+ */
88
+ addOutputSubscription(ws, agentId) {
89
+ const subscribers = this.outputSubscribers.get(agentId) ?? new Set();
90
+ subscribers.add(ws);
91
+ this.outputSubscribers.set(agentId, subscribers);
92
+ const wsAgents = this.wsSubscriptions.get(ws) ?? new Set();
93
+ wsAgents.add(agentId);
94
+ this.wsSubscriptions.set(ws, wsAgents);
95
+ }
96
+ /**
97
+ * Remove output subscriptions for a client. If agentId is provided, only that
98
+ * subscription is removed.
99
+ */
100
+ removeOutputSubscription(ws, agentId) {
101
+ const wsAgents = this.wsSubscriptions.get(ws);
102
+ if (!wsAgents)
103
+ return;
104
+ const removeForAgent = (id) => {
105
+ const agentSubscribers = this.outputSubscribers.get(id);
106
+ agentSubscribers?.delete(ws);
107
+ if (agentSubscribers && agentSubscribers.size === 0) {
108
+ this.outputSubscribers.delete(id);
109
+ }
110
+ };
111
+ if (agentId) {
112
+ wsAgents.delete(agentId);
113
+ removeForAgent(agentId);
114
+ }
115
+ else {
116
+ for (const id of wsAgents) {
117
+ removeForAgent(id);
118
+ }
119
+ this.wsSubscriptions.delete(ws);
120
+ }
121
+ }
122
+ /**
123
+ * Send a snapshot of recent output to the client.
124
+ */
125
+ sendOutputSnapshot(ws, agentId, limit) {
126
+ const normalizedLimit = this.normalizeLimit(limit) ?? 200;
127
+ const output = this.agentManager.getOutput(agentId, normalizedLimit);
128
+ if (output === null) {
129
+ this.sendToClient(ws, { type: 'error', data: { message: `Agent ${agentId} not found` } });
130
+ return false;
131
+ }
132
+ const agent = this.agentManager.get(agentId);
133
+ this.sendToClient(ws, {
134
+ type: 'output:init',
135
+ data: {
136
+ agentId,
137
+ workspaceId: agent?.workspaceId,
138
+ output,
139
+ },
140
+ });
141
+ return true;
142
+ }
143
+ /**
144
+ * Broadcast output events only to subscribed clients
145
+ */
146
+ broadcastOutputEvent(event) {
147
+ if (!this.wss || !event.agentId)
148
+ return;
149
+ const subscribers = this.outputSubscribers.get(event.agentId);
150
+ if (!subscribers?.size)
151
+ return;
152
+ const data = event.data;
153
+ const message = JSON.stringify({
154
+ type: 'output:append',
155
+ data: {
156
+ agentId: event.agentId,
157
+ workspaceId: event.workspaceId,
158
+ output: data?.output ?? event.data,
159
+ agentName: data?.agentName,
160
+ timestamp: event.timestamp,
161
+ },
162
+ });
163
+ for (const ws of subscribers) {
164
+ if (ws.readyState === WebSocket.OPEN) {
165
+ ws.send(message);
166
+ }
167
+ }
168
+ }
169
+ /**
170
+ * Clamp and normalize requested output limit
171
+ */
172
+ normalizeLimit(value) {
173
+ const num = typeof value === 'number' ? value : Number(value);
174
+ if (!Number.isFinite(num))
175
+ return undefined;
176
+ const limit = Math.floor(num);
177
+ return Math.min(2000, Math.max(1, limit));
178
+ }
179
+ /**
180
+ * Start the API server
181
+ */
182
+ async start() {
183
+ // Initialize repo manager (scans for existing repos, syncs from env)
184
+ // This runs in background - don't block server startup
185
+ initRepoManager().catch((err) => {
186
+ logger.warn('Failed to initialize repo manager', { error: String(err) });
187
+ });
188
+ return new Promise((resolve) => {
189
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
190
+ // Setup WebSocket server (disable compression for compatibility)
191
+ this.wss = new WebSocketServer({ server: this.server, perMessageDeflate: false });
192
+ this.wss.on('connection', (ws, req) => this.handleWebSocketConnection(ws, req));
193
+ // Setup ping/pong keepalive (30 second interval)
194
+ this.pingInterval = setInterval(() => {
195
+ this.wss?.clients.forEach((ws) => {
196
+ if (this.clientAlive.get(ws) === false) {
197
+ logger.info('WebSocket client unresponsive, closing');
198
+ ws.terminate();
199
+ return;
200
+ }
201
+ this.clientAlive.set(ws, false);
202
+ ws.ping();
203
+ });
204
+ }, 30000);
205
+ this.server.listen(this.config.port, this.config.host, () => {
206
+ logger.info('Daemon API started', { port: this.config.port, host: this.config.host });
207
+ resolve();
208
+ });
209
+ });
210
+ }
211
+ /**
212
+ * Stop the API server
213
+ */
214
+ async stop() {
215
+ // Clear ping interval
216
+ if (this.pingInterval) {
217
+ clearInterval(this.pingInterval);
218
+ this.pingInterval = undefined;
219
+ }
220
+ // Close all WebSocket connections
221
+ if (this.wss) {
222
+ for (const ws of this.wss.clients) {
223
+ ws.close();
224
+ }
225
+ this.wss.close();
226
+ }
227
+ // Stop agent manager
228
+ await this.agentManager.shutdown();
229
+ // Close HTTP server
230
+ if (this.server) {
231
+ return new Promise((resolve) => {
232
+ this.server.close(() => {
233
+ logger.info('Daemon API stopped');
234
+ resolve();
235
+ });
236
+ });
237
+ }
238
+ }
239
+ /**
240
+ * Setup API routes
241
+ */
242
+ setupRoutes() {
243
+ // Health check
244
+ this.routes.set('GET /', async () => ({
245
+ status: 200,
246
+ body: { status: 'ok', version: '1.0.0' },
247
+ }));
248
+ // Metrics endpoint
249
+ this.routes.set('GET /metrics', async () => ({
250
+ status: 200,
251
+ body: metrics.toPrometheus(),
252
+ headers: { 'Content-Type': 'text/plain' },
253
+ }));
254
+ // === Workspaces ===
255
+ // List workspaces
256
+ this.routes.set('GET /workspaces', async () => {
257
+ const workspaces = this.workspaceManager.getAll();
258
+ const active = this.workspaceManager.getActive();
259
+ const response = {
260
+ workspaces,
261
+ activeWorkspaceId: active?.id,
262
+ };
263
+ return { status: 200, body: response };
264
+ });
265
+ // Add workspace
266
+ this.routes.set('POST /workspaces', async (req) => {
267
+ const body = req.body;
268
+ if (!body?.path) {
269
+ return { status: 400, body: { error: 'path is required' } };
270
+ }
271
+ try {
272
+ const workspace = this.workspaceManager.add(body);
273
+ return { status: 201, body: workspace };
274
+ }
275
+ catch (err) {
276
+ return { status: 400, body: { error: String(err) } };
277
+ }
278
+ });
279
+ // Get workspace
280
+ this.routes.set('GET /workspaces/:id', async (req) => {
281
+ const workspace = this.workspaceManager.get(req.params.id);
282
+ if (!workspace) {
283
+ return { status: 404, body: { error: 'Workspace not found' } };
284
+ }
285
+ return { status: 200, body: workspace };
286
+ });
287
+ // Delete workspace
288
+ this.routes.set('DELETE /workspaces/:id', async (req) => {
289
+ const removed = this.workspaceManager.remove(req.params.id);
290
+ if (!removed) {
291
+ return { status: 404, body: { error: 'Workspace not found' } };
292
+ }
293
+ return { status: 204, body: null };
294
+ });
295
+ // Switch workspace
296
+ this.routes.set('POST /workspaces/:id/switch', async (req) => {
297
+ try {
298
+ const workspace = this.workspaceManager.switchTo(req.params.id);
299
+ return { status: 200, body: workspace };
300
+ }
301
+ catch (err) {
302
+ return { status: 404, body: { error: String(err) } };
303
+ }
304
+ });
305
+ // === Agents ===
306
+ // List agents in workspace
307
+ this.routes.set('GET /workspaces/:id/agents', async (req) => {
308
+ const workspace = this.workspaceManager.get(req.params.id);
309
+ if (!workspace) {
310
+ return { status: 404, body: { error: 'Workspace not found' } };
311
+ }
312
+ const agents = this.agentManager.getByWorkspace(req.params.id);
313
+ const response = {
314
+ agents,
315
+ workspaceId: req.params.id,
316
+ };
317
+ return { status: 200, body: response };
318
+ });
319
+ // Spawn agent in workspace
320
+ this.routes.set('POST /workspaces/:id/agents', async (req) => {
321
+ const workspace = this.workspaceManager.get(req.params.id);
322
+ if (!workspace) {
323
+ return { status: 404, body: { error: 'Workspace not found' } };
324
+ }
325
+ const body = req.body;
326
+ if (!body?.name) {
327
+ return { status: 400, body: { error: 'name is required' } };
328
+ }
329
+ try {
330
+ const agent = await this.agentManager.spawn(req.params.id, workspace.path, body);
331
+ return { status: 201, body: agent };
332
+ }
333
+ catch (err) {
334
+ return { status: 400, body: { error: String(err) } };
335
+ }
336
+ });
337
+ // Get agent
338
+ this.routes.set('GET /agents/:id', async (req) => {
339
+ const agent = this.agentManager.get(req.params.id);
340
+ if (!agent) {
341
+ return { status: 404, body: { error: 'Agent not found' } };
342
+ }
343
+ return { status: 200, body: agent };
344
+ });
345
+ // Stop agent
346
+ this.routes.set('DELETE /agents/:id', async (req) => {
347
+ const stopped = await this.agentManager.stop(req.params.id);
348
+ if (!stopped) {
349
+ return { status: 404, body: { error: 'Agent not found' } };
350
+ }
351
+ return { status: 204, body: null };
352
+ });
353
+ // Get agent output
354
+ this.routes.set('GET /agents/:id/output', async (req) => {
355
+ const limit = req.query.limit ? parseInt(req.query.limit, 10) : undefined;
356
+ const output = this.agentManager.getOutput(req.params.id, limit);
357
+ if (output === null) {
358
+ return { status: 404, body: { error: 'Agent not found' } };
359
+ }
360
+ return { status: 200, body: { output } };
361
+ });
362
+ // Send input to agent
363
+ this.routes.set('POST /agents/:id/input', async (req) => {
364
+ const body = req.body;
365
+ if (!body?.input) {
366
+ return { status: 400, body: { error: 'input is required' } };
367
+ }
368
+ const sent = this.agentManager.sendInput(req.params.id, body.input);
369
+ if (!sent) {
370
+ return { status: 404, body: { error: 'Agent not found' } };
371
+ }
372
+ return { status: 200, body: { success: true } };
373
+ });
374
+ // Interrupt agent by ID (send Ctrl+C to break out of stuck loops)
375
+ this.routes.set('POST /agents/:id/interrupt', async (req) => {
376
+ const interrupted = this.agentManager.interrupt(req.params.id);
377
+ if (!interrupted) {
378
+ return { status: 404, body: { error: 'Agent not found' } };
379
+ }
380
+ return { status: 200, body: { success: true } };
381
+ });
382
+ // Interrupt agent by name (for dashboard where only name is available)
383
+ this.routes.set('POST /agents/by-name/:name/interrupt', async (req) => {
384
+ const interrupted = this.agentManager.interruptByName(req.params.name);
385
+ if (!interrupted) {
386
+ return { status: 404, body: { error: 'Agent not found' } };
387
+ }
388
+ return { status: 200, body: { success: true } };
389
+ });
390
+ // === All Agents ===
391
+ // List all agents
392
+ this.routes.set('GET /agents', async () => {
393
+ const agents = this.agentManager.getAll();
394
+ return { status: 200, body: { agents } };
395
+ });
396
+ // === CLI Auth (for cloud server to call) ===
397
+ // List supported providers
398
+ this.routes.set('GET /auth/providers', async () => {
399
+ return { status: 200, body: { providers: getSupportedProviders() } };
400
+ });
401
+ // Start CLI auth flow
402
+ this.routes.set('POST /auth/cli/:provider/start', async (req) => {
403
+ const { provider } = req.params;
404
+ try {
405
+ const session = await startCLIAuth(provider);
406
+ return {
407
+ status: 200,
408
+ body: {
409
+ sessionId: session.id,
410
+ status: session.status,
411
+ authUrl: session.authUrl,
412
+ },
413
+ };
414
+ }
415
+ catch (err) {
416
+ return {
417
+ status: 400,
418
+ body: { error: err instanceof Error ? err.message : 'Failed to start auth' },
419
+ };
420
+ }
421
+ });
422
+ // Get auth session status
423
+ this.routes.set('GET /auth/cli/:provider/status/:sessionId', async (req) => {
424
+ const { sessionId } = req.params;
425
+ const session = getAuthSession(sessionId);
426
+ if (!session) {
427
+ return { status: 404, body: { error: 'Session not found' } };
428
+ }
429
+ return {
430
+ status: 200,
431
+ body: {
432
+ sessionId: session.id,
433
+ status: session.status,
434
+ authUrl: session.authUrl,
435
+ error: session.error,
436
+ errorHint: session.errorHint,
437
+ recoverable: session.recoverable,
438
+ promptsHandled: session.promptsHandled,
439
+ },
440
+ };
441
+ });
442
+ // Get credentials from completed auth
443
+ this.routes.set('GET /auth/cli/:provider/creds/:sessionId', async (req) => {
444
+ const { sessionId } = req.params;
445
+ const session = getAuthSession(sessionId);
446
+ if (!session) {
447
+ return { status: 404, body: { error: 'Session not found' } };
448
+ }
449
+ // Check for error state first
450
+ if (session.status === 'error') {
451
+ return {
452
+ status: 400,
453
+ body: {
454
+ error: session.error || 'Authentication failed',
455
+ errorHint: session.errorHint,
456
+ recoverable: session.recoverable,
457
+ status: session.status,
458
+ },
459
+ };
460
+ }
461
+ // Check if auth is complete AND we have credentials
462
+ // Status can be 'success' before credentials are extracted (race condition)
463
+ if (session.status !== 'success' || !session.token) {
464
+ return {
465
+ status: 400,
466
+ body: {
467
+ error: 'Auth not complete or credentials not yet available',
468
+ status: session.status,
469
+ hasToken: !!session.token,
470
+ },
471
+ };
472
+ }
473
+ return {
474
+ status: 200,
475
+ body: {
476
+ token: session.token,
477
+ refreshToken: session.refreshToken,
478
+ tokenExpiresAt: session.tokenExpiresAt,
479
+ provider: session.provider,
480
+ },
481
+ };
482
+ });
483
+ // Submit auth code to PTY session
484
+ this.routes.set('POST /auth/cli/:provider/code/:sessionId', async (req) => {
485
+ const { sessionId } = req.params;
486
+ const { code, state } = req.body;
487
+ if (!code || typeof code !== 'string') {
488
+ return { status: 400, body: { error: 'Auth code is required' } };
489
+ }
490
+ const result = await submitAuthCode(sessionId, code, state);
491
+ if (!result.success) {
492
+ return {
493
+ status: 400,
494
+ body: {
495
+ error: result.error || 'Failed to submit auth code',
496
+ needsRestart: result.needsRestart,
497
+ },
498
+ };
499
+ }
500
+ return { status: 200, body: { success: true, message: 'Auth code submitted' } };
501
+ });
502
+ // Complete auth and wait for credentials
503
+ this.routes.set('POST /auth/cli/:provider/complete/:sessionId', async (req) => {
504
+ const { sessionId } = req.params;
505
+ const { authCode, state } = req.body;
506
+ // For Codex, we need to forward the authCode to the CLI's callback server
507
+ // The Codex CLI starts a callback server at localhost:1455
508
+ if (authCode) {
509
+ try {
510
+ // Forward the OAuth callback to the Codex CLI's callback server
511
+ const callbackUrl = `http://localhost:1455/auth/callback?code=${encodeURIComponent(authCode)}${state ? `&state=${encodeURIComponent(state)}` : ''}`;
512
+ logger.info('Forwarding OAuth callback to Codex CLI', { callbackUrl: callbackUrl.replace(authCode, '[REDACTED]') });
513
+ const callbackResponse = await fetch(callbackUrl, {
514
+ signal: AbortSignal.timeout(5000),
515
+ });
516
+ if (!callbackResponse.ok) {
517
+ logger.error('Failed to forward callback to Codex CLI', { status: callbackResponse.status });
518
+ return {
519
+ status: 400,
520
+ body: {
521
+ error: 'Failed to deliver OAuth callback to Codex CLI. The CLI may have timed out.',
522
+ needsRestart: true,
523
+ },
524
+ };
525
+ }
526
+ logger.info('Successfully forwarded OAuth callback to Codex CLI');
527
+ }
528
+ catch (err) {
529
+ logger.error('Error forwarding callback to Codex CLI', { error: String(err) });
530
+ return {
531
+ status: 500,
532
+ body: {
533
+ error: 'Failed to reach Codex CLI callback server. The CLI may not be running.',
534
+ needsRestart: true,
535
+ },
536
+ };
537
+ }
538
+ }
539
+ // Wait for credentials to be available (polls for up to 15 seconds)
540
+ const { completeAuthSession } = await import('./cli-auth.js');
541
+ const completeResult = await completeAuthSession(sessionId);
542
+ if (!completeResult.success) {
543
+ return {
544
+ status: 400,
545
+ body: {
546
+ error: completeResult.error || 'Authentication failed',
547
+ },
548
+ };
549
+ }
550
+ return { status: 200, body: { success: true, token: completeResult.token } };
551
+ });
552
+ // Cancel auth session
553
+ this.routes.set('POST /auth/cli/:provider/cancel/:sessionId', async (req) => {
554
+ const { sessionId } = req.params;
555
+ const cancelled = cancelAuthSession(sessionId);
556
+ if (!cancelled) {
557
+ return { status: 404, body: { error: 'Session not found' } };
558
+ }
559
+ return { status: 200, body: { success: true } };
560
+ });
561
+ // Check if provider is authenticated (credentials exist)
562
+ this.routes.set('GET /auth/cli/:provider/check', async (req) => {
563
+ const { provider } = req.params;
564
+ const userId = req.query.userId;
565
+ const { checkProviderAuth } = await import('./cli-auth.js');
566
+ const authenticated = await checkProviderAuth(provider, userId);
567
+ return { status: 200, body: { authenticated } };
568
+ });
569
+ // === Repository Management ===
570
+ // Dynamic repo management without workspace restart
571
+ // List all repos
572
+ this.routes.set('GET /repos', async () => {
573
+ try {
574
+ const repoManager = getRepoManager();
575
+ const repos = repoManager.getRepos();
576
+ return { status: 200, body: { repos } };
577
+ }
578
+ catch (err) {
579
+ logger.error('Failed to list repos', { error: String(err) });
580
+ return { status: 500, body: { error: 'Failed to list repositories' } };
581
+ }
582
+ });
583
+ // Get a specific repo
584
+ this.routes.set('GET /repos/:name', async (req) => {
585
+ try {
586
+ const repoManager = getRepoManager();
587
+ // Handle encoded slashes (e.g., "owner%2Frepo" -> "owner/repo")
588
+ const fullName = decodeURIComponent(req.params.name);
589
+ const repo = repoManager.getRepo(fullName);
590
+ if (!repo) {
591
+ return { status: 404, body: { error: 'Repository not found' } };
592
+ }
593
+ return { status: 200, body: repo };
594
+ }
595
+ catch (err) {
596
+ logger.error('Failed to get repo', { error: String(err) });
597
+ return { status: 500, body: { error: 'Failed to get repository' } };
598
+ }
599
+ });
600
+ // Sync (clone or update) a repo
601
+ this.routes.set('POST /repos/sync', async (req) => {
602
+ const body = req.body;
603
+ // Support single repo or batch
604
+ const reposToSync = [];
605
+ if (body.repo) {
606
+ reposToSync.push(body.repo);
607
+ }
608
+ if (body.repos && Array.isArray(body.repos)) {
609
+ reposToSync.push(...body.repos);
610
+ }
611
+ if (reposToSync.length === 0) {
612
+ return { status: 400, body: { error: 'repo or repos field is required' } };
613
+ }
614
+ try {
615
+ const repoManager = getRepoManager();
616
+ const results = await repoManager.syncRepos(reposToSync);
617
+ const allSuccess = results.every(r => r.success);
618
+ return {
619
+ status: allSuccess ? 200 : 207, // 207 Multi-Status if partial success
620
+ body: { results },
621
+ };
622
+ }
623
+ catch (err) {
624
+ logger.error('Failed to sync repos', { error: String(err) });
625
+ return { status: 500, body: { error: 'Failed to sync repositories' } };
626
+ }
627
+ });
628
+ // Remove a repo
629
+ this.routes.set('DELETE /repos/:name', async (req) => {
630
+ try {
631
+ const repoManager = getRepoManager();
632
+ const fullName = decodeURIComponent(req.params.name);
633
+ const deleteFiles = req.query.deleteFiles === 'true';
634
+ const removed = await repoManager.removeRepo(fullName, deleteFiles);
635
+ if (!removed) {
636
+ return { status: 404, body: { error: 'Repository not found' } };
637
+ }
638
+ return { status: 200, body: { success: true, deleted: deleteFiles } };
639
+ }
640
+ catch (err) {
641
+ logger.error('Failed to remove repo', { error: String(err) });
642
+ return { status: 500, body: { error: 'Failed to remove repository' } };
643
+ }
644
+ });
645
+ }
646
+ /**
647
+ * Handle HTTP request
648
+ */
649
+ async handleRequest(req, res) {
650
+ const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
651
+ const allowedOrigin = this.resolveAllowedOrigin(originHeader, req.headers.host);
652
+ // CORS headers (default denies cross-origin unless explicitly allowed)
653
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
654
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
655
+ res.setHeader('Vary', 'Origin');
656
+ if (allowedOrigin === null) {
657
+ logger.warn('CORS origin blocked', { origin: originHeader });
658
+ res.writeHead(403, { 'Content-Type': 'application/json' });
659
+ res.end(JSON.stringify({ error: 'CORS origin not allowed' }));
660
+ return;
661
+ }
662
+ if (allowedOrigin) {
663
+ res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
664
+ }
665
+ if (req.method === 'OPTIONS') {
666
+ res.writeHead(204);
667
+ res.end();
668
+ return;
669
+ }
670
+ try {
671
+ const url = new URL(req.url || '/', `http://${req.headers.host}`);
672
+ const apiReq = await this.parseRequest(req, url);
673
+ // Find matching route
674
+ const response = await this.routeRequest(apiReq);
675
+ // Send response
676
+ res.writeHead(response.status, {
677
+ 'Content-Type': 'application/json',
678
+ ...response.headers,
679
+ });
680
+ if (response.body !== null) {
681
+ const body = typeof response.body === 'string' ? response.body : JSON.stringify(response.body);
682
+ res.end(body);
683
+ }
684
+ else {
685
+ res.end();
686
+ }
687
+ }
688
+ catch (err) {
689
+ logger.error('Request error', { error: String(err) });
690
+ res.writeHead(500, { 'Content-Type': 'application/json' });
691
+ res.end(JSON.stringify({ error: 'Internal server error' }));
692
+ }
693
+ }
694
+ /**
695
+ * Parse incoming request
696
+ */
697
+ async parseRequest(req, url) {
698
+ // Parse query params
699
+ const query = {};
700
+ url.searchParams.forEach((value, key) => {
701
+ query[key] = value;
702
+ });
703
+ // Parse body for POST/PUT
704
+ let body;
705
+ if (req.method === 'POST' || req.method === 'PUT') {
706
+ body = await this.parseBody(req);
707
+ }
708
+ return {
709
+ method: req.method || 'GET',
710
+ path: url.pathname,
711
+ body,
712
+ params: {},
713
+ query,
714
+ };
715
+ }
716
+ /**
717
+ * Parse request body
718
+ */
719
+ parseBody(req) {
720
+ return new Promise((resolve, reject) => {
721
+ let data = '';
722
+ req.on('data', (chunk) => {
723
+ data += chunk;
724
+ });
725
+ req.on('end', () => {
726
+ try {
727
+ resolve(data ? JSON.parse(data) : undefined);
728
+ }
729
+ catch {
730
+ reject(new Error('Invalid JSON body'));
731
+ }
732
+ });
733
+ req.on('error', reject);
734
+ });
735
+ }
736
+ /**
737
+ * Route request to handler
738
+ */
739
+ async routeRequest(req) {
740
+ for (const [pattern, handler] of this.routes) {
741
+ const match = this.matchRoute(pattern, req.method, req.path);
742
+ if (match) {
743
+ req.params = match.params;
744
+ return handler(req);
745
+ }
746
+ }
747
+ return { status: 404, body: { error: 'Not found' } };
748
+ }
749
+ /**
750
+ * Match route pattern against request
751
+ */
752
+ matchRoute(pattern, method, path) {
753
+ const [patternMethod, patternPath] = pattern.split(' ');
754
+ if (patternMethod !== method) {
755
+ return null;
756
+ }
757
+ const patternParts = patternPath.split('/');
758
+ const pathParts = path.split('/');
759
+ if (patternParts.length !== pathParts.length) {
760
+ return null;
761
+ }
762
+ const params = {};
763
+ for (let i = 0; i < patternParts.length; i++) {
764
+ const patternPart = patternParts[i];
765
+ const pathPart = pathParts[i];
766
+ if (patternPart.startsWith(':')) {
767
+ params[patternPart.slice(1)] = pathPart;
768
+ }
769
+ else if (patternPart !== pathPart) {
770
+ return null;
771
+ }
772
+ }
773
+ return { params };
774
+ }
775
+ /**
776
+ * Handle WebSocket connection
777
+ */
778
+ handleWebSocketConnection(ws, req) {
779
+ logger.info('WebSocket client connected', { url: req.url });
780
+ // Mark client as alive for ping/pong keepalive
781
+ this.clientAlive.set(ws, true);
782
+ // Handle pong responses
783
+ ws.on('pong', () => {
784
+ this.clientAlive.set(ws, true);
785
+ });
786
+ // Create session
787
+ const session = {
788
+ userId: 'anonymous', // Would be set from auth
789
+ githubUsername: 'anonymous',
790
+ connectedAt: new Date(),
791
+ };
792
+ this.sessions.set(ws, session);
793
+ // Send initial state
794
+ this.sendInitialState(ws);
795
+ // Handle messages
796
+ ws.on('message', (data) => {
797
+ try {
798
+ const message = JSON.parse(data.toString());
799
+ this.handleWebSocketMessage(ws, session, message);
800
+ }
801
+ catch (err) {
802
+ logger.error('WebSocket message error', { error: String(err) });
803
+ }
804
+ });
805
+ ws.on('close', () => {
806
+ logger.info('WebSocket client disconnected');
807
+ this.sessions.delete(ws);
808
+ });
809
+ ws.on('error', (err) => {
810
+ logger.error('WebSocket error', { error: String(err) });
811
+ });
812
+ }
813
+ /**
814
+ * Send initial state to WebSocket client
815
+ */
816
+ sendInitialState(ws) {
817
+ const workspaces = this.workspaceManager.getAll();
818
+ const active = this.workspaceManager.getActive();
819
+ const agents = this.agentManager.getAll();
820
+ this.sendToClient(ws, {
821
+ type: 'init',
822
+ data: {
823
+ workspaces,
824
+ activeWorkspaceId: active?.id,
825
+ agents,
826
+ },
827
+ });
828
+ }
829
+ /**
830
+ * Handle WebSocket message from client
831
+ */
832
+ handleWebSocketMessage(ws, session, message) {
833
+ switch (message.type) {
834
+ case 'switch_workspace':
835
+ if (typeof message.data === 'string') {
836
+ try {
837
+ this.workspaceManager.switchTo(message.data);
838
+ session.activeWorkspaceId = message.data;
839
+ }
840
+ catch (err) {
841
+ this.sendToClient(ws, { type: 'error', data: String(err) });
842
+ }
843
+ }
844
+ break;
845
+ case 'subscribe_output':
846
+ // Subscribe to agent output stream
847
+ // TODO: Implement output streaming
848
+ break;
849
+ case 'ping':
850
+ this.sendToClient(ws, { type: 'pong' });
851
+ break;
852
+ }
853
+ }
854
+ /**
855
+ * Send message to WebSocket client
856
+ */
857
+ sendToClient(ws, message) {
858
+ if (ws.readyState === WebSocket.OPEN) {
859
+ ws.send(JSON.stringify(message));
860
+ }
861
+ }
862
+ /**
863
+ * Broadcast event to all WebSocket clients
864
+ */
865
+ broadcastEvent(event) {
866
+ if (!this.wss)
867
+ return;
868
+ const message = JSON.stringify({ type: 'event', data: event });
869
+ for (const ws of this.wss.clients) {
870
+ if (ws.readyState === WebSocket.OPEN) {
871
+ ws.send(message);
872
+ }
873
+ }
874
+ }
875
+ }
876
+ //# sourceMappingURL=api.js.map