agileflow 3.4.2 → 3.4.3
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/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/lib/drivers/claude-driver.ts +1 -1
- package/lib/lazy-require.js +1 -1
- package/package.json +1 -1
- package/scripts/agent-loop.js +290 -230
- package/scripts/check-sessions.js +116 -0
- package/scripts/lib/quality-gates.js +35 -8
- package/scripts/lib/signal-detectors.js +0 -13
- package/scripts/lib/team-events.js +1 -1
- package/scripts/lib/tmux-audit-monitor.js +2 -1
- package/src/core/commands/ads/audit.md +19 -3
- package/src/core/commands/code/accessibility.md +22 -6
- package/src/core/commands/code/api.md +22 -6
- package/src/core/commands/code/architecture.md +22 -6
- package/src/core/commands/code/completeness.md +22 -6
- package/src/core/commands/code/legal.md +22 -6
- package/src/core/commands/code/logic.md +22 -6
- package/src/core/commands/code/performance.md +22 -6
- package/src/core/commands/code/security.md +22 -6
- package/src/core/commands/code/test.md +22 -6
- package/src/core/commands/ideate/features.md +5 -4
- package/src/core/commands/ideate/new.md +8 -7
- package/src/core/commands/seo/audit.md +21 -5
- package/lib/claude-cli-bridge.js +0 -215
- package/lib/dashboard-automations.js +0 -130
- package/lib/dashboard-git.js +0 -254
- package/lib/dashboard-inbox.js +0 -64
- package/lib/dashboard-protocol.js +0 -605
- package/lib/dashboard-server.js +0 -1296
- package/lib/dashboard-session.js +0 -136
- package/lib/dashboard-status.js +0 -72
- package/lib/dashboard-terminal.js +0 -354
- package/lib/dashboard-websocket.js +0 -88
- package/scripts/dashboard-serve.js +0 -336
- package/src/core/commands/serve.md +0 -127
- package/tools/cli/commands/serve.js +0 -492
package/lib/dashboard-server.js
DELETED
|
@@ -1,1296 +0,0 @@
|
|
|
1
|
-
/* global URL */
|
|
2
|
-
/**
|
|
3
|
-
* dashboard-server.js - WebSocket Server for AgileFlow Dashboard
|
|
4
|
-
*
|
|
5
|
-
* Coordinator module that delegates to focused domain modules:
|
|
6
|
-
* - dashboard-websocket.js - WebSocket frame encode/decode
|
|
7
|
-
* - dashboard-session.js - Session lifecycle and rate limiting
|
|
8
|
-
* - dashboard-terminal.js - Terminal management (PTY/fallback)
|
|
9
|
-
* - dashboard-git.js - Git operations (status, diff, actions)
|
|
10
|
-
* - dashboard-automations.js - Automation scheduling
|
|
11
|
-
* - dashboard-status.js - Project status and team metrics
|
|
12
|
-
* - dashboard-inbox.js - Inbox management
|
|
13
|
-
*
|
|
14
|
-
* Usage:
|
|
15
|
-
* const { createDashboardServer, startDashboardServer } = require('./dashboard-server');
|
|
16
|
-
*
|
|
17
|
-
* const server = createDashboardServer({ port: 8765 });
|
|
18
|
-
* await startDashboardServer(server);
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
'use strict';
|
|
22
|
-
|
|
23
|
-
const { EventEmitter } = require('events');
|
|
24
|
-
|
|
25
|
-
// Import extracted modules
|
|
26
|
-
const { encodeWebSocketFrame, decodeWebSocketFrame } = require('./dashboard-websocket');
|
|
27
|
-
const {
|
|
28
|
-
DashboardSession,
|
|
29
|
-
SESSION_TIMEOUT_MS,
|
|
30
|
-
SESSION_CLEANUP_INTERVAL_MS,
|
|
31
|
-
RATE_LIMIT_TOKENS,
|
|
32
|
-
} = require('./dashboard-session');
|
|
33
|
-
const {
|
|
34
|
-
TerminalInstance,
|
|
35
|
-
TerminalManager,
|
|
36
|
-
SENSITIVE_ENV_PATTERNS,
|
|
37
|
-
} = require('./dashboard-terminal');
|
|
38
|
-
const { getGitStatus, getFileDiff, parseDiffStats, handleGitAction } = require('./dashboard-git');
|
|
39
|
-
const {
|
|
40
|
-
calculateNextRun,
|
|
41
|
-
createInboxItem,
|
|
42
|
-
enrichAutomationList,
|
|
43
|
-
} = require('./dashboard-automations');
|
|
44
|
-
const { buildStatusSummary, readTeamMetrics } = require('./dashboard-status');
|
|
45
|
-
const { getSortedInboxItems, handleInboxAction } = require('./dashboard-inbox');
|
|
46
|
-
|
|
47
|
-
// Lazy-loaded dependencies - deferred until first use
|
|
48
|
-
let _http, _crypto, _protocol, _paths, _validatePaths, _childProcess;
|
|
49
|
-
|
|
50
|
-
function getHttp() {
|
|
51
|
-
if (!_http) _http = require('http');
|
|
52
|
-
return _http;
|
|
53
|
-
}
|
|
54
|
-
function getCrypto() {
|
|
55
|
-
if (!_crypto) _crypto = require('crypto');
|
|
56
|
-
return _crypto;
|
|
57
|
-
}
|
|
58
|
-
function getProtocol() {
|
|
59
|
-
if (!_protocol) _protocol = require('./dashboard-protocol');
|
|
60
|
-
return _protocol;
|
|
61
|
-
}
|
|
62
|
-
function getPaths() {
|
|
63
|
-
if (!_paths) _paths = require('./paths');
|
|
64
|
-
return _paths;
|
|
65
|
-
}
|
|
66
|
-
function getValidatePaths() {
|
|
67
|
-
if (!_validatePaths) _validatePaths = require('./validate-paths');
|
|
68
|
-
return _validatePaths;
|
|
69
|
-
}
|
|
70
|
-
function getChildProcess() {
|
|
71
|
-
if (!_childProcess) _childProcess = require('child_process');
|
|
72
|
-
return _childProcess;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Lazy-load automation modules to avoid circular dependencies
|
|
76
|
-
let AutomationRegistry = null;
|
|
77
|
-
let AutomationRunner = null;
|
|
78
|
-
|
|
79
|
-
function getAutomationRegistry(rootDir) {
|
|
80
|
-
if (!AutomationRegistry) {
|
|
81
|
-
const mod = require('../scripts/lib/automation-registry');
|
|
82
|
-
AutomationRegistry = mod.getAutomationRegistry;
|
|
83
|
-
}
|
|
84
|
-
return AutomationRegistry({ rootDir });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function getAutomationRunner(rootDir) {
|
|
88
|
-
if (!AutomationRunner) {
|
|
89
|
-
const mod = require('../scripts/lib/automation-runner');
|
|
90
|
-
AutomationRunner = mod.getAutomationRunner;
|
|
91
|
-
}
|
|
92
|
-
return AutomationRunner({ rootDir });
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Default configuration
|
|
96
|
-
const DEFAULT_PORT = 8765;
|
|
97
|
-
const DEFAULT_HOST = '127.0.0.1'; // Localhost only for security
|
|
98
|
-
|
|
99
|
-
// WebSocket magic GUID for handshake
|
|
100
|
-
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Dashboard WebSocket Server
|
|
104
|
-
*/
|
|
105
|
-
class DashboardServer extends EventEmitter {
|
|
106
|
-
constructor(options = {}) {
|
|
107
|
-
super();
|
|
108
|
-
|
|
109
|
-
this.port = options.port || DEFAULT_PORT;
|
|
110
|
-
this.host = options.host || DEFAULT_HOST;
|
|
111
|
-
this.projectRoot = options.projectRoot || getPaths().getProjectRoot();
|
|
112
|
-
|
|
113
|
-
// Auth is on by default - auto-generate key if not provided
|
|
114
|
-
// Set requireAuth: false explicitly to disable
|
|
115
|
-
this.requireAuth = options.requireAuth !== false;
|
|
116
|
-
this.apiKey =
|
|
117
|
-
options.apiKey || (this.requireAuth ? getCrypto().randomBytes(32).toString('hex') : null);
|
|
118
|
-
|
|
119
|
-
// Session management
|
|
120
|
-
this.sessions = new Map();
|
|
121
|
-
|
|
122
|
-
// Terminal management
|
|
123
|
-
this.terminalManager = new TerminalManager();
|
|
124
|
-
|
|
125
|
-
// Automation management
|
|
126
|
-
this._automationRegistry = null;
|
|
127
|
-
this._automationRunner = null;
|
|
128
|
-
this._runningAutomations = new Map(); // automationId -> { startTime, session }
|
|
129
|
-
|
|
130
|
-
// Inbox management
|
|
131
|
-
this._inbox = new Map(); // itemId -> InboxItem
|
|
132
|
-
|
|
133
|
-
// Session cleanup interval
|
|
134
|
-
this._cleanupInterval = null;
|
|
135
|
-
|
|
136
|
-
// HTTP server for WebSocket upgrade
|
|
137
|
-
this.httpServer = null;
|
|
138
|
-
|
|
139
|
-
// Validate project
|
|
140
|
-
if (!getPaths().isAgileflowProject(this.projectRoot)) {
|
|
141
|
-
throw new Error(`Not an AgileFlow project: ${this.projectRoot}`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Initialize automation registry lazily
|
|
145
|
-
this._initAutomations();
|
|
146
|
-
|
|
147
|
-
// Listen for team metrics saves to broadcast to clients
|
|
148
|
-
this._initTeamMetricsListener();
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Initialize automation registry and runner
|
|
153
|
-
*/
|
|
154
|
-
_initAutomations() {
|
|
155
|
-
try {
|
|
156
|
-
this._automationRegistry = getAutomationRegistry(this.projectRoot);
|
|
157
|
-
this._automationRunner = getAutomationRunner(this.projectRoot);
|
|
158
|
-
|
|
159
|
-
// Listen to runner events
|
|
160
|
-
this._automationRunner.on('started', ({ automationId }) => {
|
|
161
|
-
this._runningAutomations.set(automationId, { startTime: Date.now() });
|
|
162
|
-
this.broadcast(getProtocol().createAutomationStatus(automationId, 'running'));
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
this._automationRunner.on('completed', ({ automationId, result }) => {
|
|
166
|
-
this._runningAutomations.delete(automationId);
|
|
167
|
-
this.broadcast(getProtocol().createAutomationStatus(automationId, 'completed', result));
|
|
168
|
-
|
|
169
|
-
// Add result to inbox if it has output or changes
|
|
170
|
-
if (result.output || result.changes) {
|
|
171
|
-
this._addToInbox(automationId, result);
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
this._automationRunner.on('failed', ({ automationId, result }) => {
|
|
176
|
-
this._runningAutomations.delete(automationId);
|
|
177
|
-
this.broadcast(
|
|
178
|
-
getProtocol().createAutomationStatus(automationId, 'error', { error: result.error })
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
// Add failure to inbox
|
|
182
|
-
this._addToInbox(automationId, result);
|
|
183
|
-
});
|
|
184
|
-
} catch (error) {
|
|
185
|
-
console.error('[DashboardServer] Failed to init automations:', error.message);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Add an automation result to the inbox
|
|
191
|
-
*/
|
|
192
|
-
_addToInbox(automationId, result) {
|
|
193
|
-
const automation = this._automationRegistry?.get(automationId);
|
|
194
|
-
const item = createInboxItem(automationId, result, automation?.name);
|
|
195
|
-
this._inbox.set(item.id, item);
|
|
196
|
-
this.broadcast(getProtocol().createInboxItem(item));
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Start the WebSocket server
|
|
201
|
-
* @returns {Promise<{ url: string, wsUrl: string }>}
|
|
202
|
-
*/
|
|
203
|
-
start() {
|
|
204
|
-
return new Promise((resolve, reject) => {
|
|
205
|
-
const securityHeaders = {
|
|
206
|
-
'Content-Type': 'application/json',
|
|
207
|
-
'X-Content-Type-Options': 'nosniff',
|
|
208
|
-
'X-Frame-Options': 'DENY',
|
|
209
|
-
'Cache-Control': 'no-store',
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
this.httpServer = getHttp().createServer((req, res) => {
|
|
213
|
-
// Simple health check endpoint
|
|
214
|
-
if (req.url === '/health') {
|
|
215
|
-
res.writeHead(200, securityHeaders);
|
|
216
|
-
res.end(
|
|
217
|
-
JSON.stringify({
|
|
218
|
-
status: 'ok',
|
|
219
|
-
sessions: this.sessions.size,
|
|
220
|
-
project: require('path').basename(this.projectRoot),
|
|
221
|
-
})
|
|
222
|
-
);
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Info endpoint
|
|
227
|
-
if (req.url === '/') {
|
|
228
|
-
res.writeHead(200, securityHeaders);
|
|
229
|
-
res.end(
|
|
230
|
-
JSON.stringify({
|
|
231
|
-
name: 'AgileFlow Dashboard Server',
|
|
232
|
-
version: '1.0.0',
|
|
233
|
-
ws: `ws://${this.host === '127.0.0.1' ? 'localhost' : this.host}:${this.port}`,
|
|
234
|
-
sessions: this.sessions.size,
|
|
235
|
-
})
|
|
236
|
-
);
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
res.writeHead(404);
|
|
241
|
-
res.end();
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
// Handle WebSocket upgrade
|
|
245
|
-
this.httpServer.on('upgrade', (req, socket, head) => {
|
|
246
|
-
this.handleUpgrade(req, socket, head);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
this.httpServer.on('error', err => {
|
|
250
|
-
if (err.code === 'EADDRINUSE') {
|
|
251
|
-
reject(new Error(`Port ${this.port} is already in use`));
|
|
252
|
-
} else {
|
|
253
|
-
reject(err);
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
this.httpServer.listen(this.port, this.host, () => {
|
|
258
|
-
const url = `http://${this.host === '0.0.0.0' ? 'localhost' : this.host}:${this.port}`;
|
|
259
|
-
const wsUrl = `ws://${this.host === '0.0.0.0' ? 'localhost' : this.host}:${this.port}`;
|
|
260
|
-
|
|
261
|
-
console.log(`\n[AgileFlow Dashboard Server]`);
|
|
262
|
-
console.log(` WebSocket: ${wsUrl}`);
|
|
263
|
-
console.log(` Health: ${url}/health`);
|
|
264
|
-
console.log(` Project: ${this.projectRoot}`);
|
|
265
|
-
console.log(` Auth: ${this.requireAuth ? 'Required' : 'Not required'}`);
|
|
266
|
-
if (this.requireAuth && this.apiKey) {
|
|
267
|
-
console.log(` API Key: ${this.apiKey.slice(0, 8)}...`);
|
|
268
|
-
}
|
|
269
|
-
console.log('');
|
|
270
|
-
|
|
271
|
-
// Start session cleanup interval
|
|
272
|
-
this._cleanupInterval = setInterval(() => {
|
|
273
|
-
this._cleanupExpiredSessions();
|
|
274
|
-
}, SESSION_CLEANUP_INTERVAL_MS);
|
|
275
|
-
this._cleanupInterval.unref();
|
|
276
|
-
|
|
277
|
-
resolve({ url, wsUrl, apiKey: this.apiKey });
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Handle WebSocket upgrade request
|
|
284
|
-
*/
|
|
285
|
-
handleUpgrade(req, socket, head) {
|
|
286
|
-
// Validate WebSocket upgrade headers
|
|
287
|
-
if (req.headers.upgrade?.toLowerCase() !== 'websocket') {
|
|
288
|
-
socket.destroy();
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Check API key if required
|
|
293
|
-
if (this.requireAuth && this.apiKey) {
|
|
294
|
-
const authHeader = req.headers['x-api-key'] || req.headers.authorization;
|
|
295
|
-
const providedKey = authHeader?.replace('Bearer ', '') || '';
|
|
296
|
-
|
|
297
|
-
// Use timing-safe comparison to prevent timing attacks
|
|
298
|
-
const keyBuffer = Buffer.from(this.apiKey, 'utf8');
|
|
299
|
-
const providedBuffer = Buffer.from(providedKey, 'utf8');
|
|
300
|
-
if (
|
|
301
|
-
keyBuffer.length !== providedBuffer.length ||
|
|
302
|
-
!getCrypto().timingSafeEqual(keyBuffer, providedBuffer)
|
|
303
|
-
) {
|
|
304
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
305
|
-
socket.destroy();
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Check WebSocket origin against localhost allowlist
|
|
311
|
-
const origin = req.headers.origin;
|
|
312
|
-
if (origin) {
|
|
313
|
-
const LOCALHOST_ORIGINS = [
|
|
314
|
-
'http://localhost',
|
|
315
|
-
'https://localhost',
|
|
316
|
-
'http://127.0.0.1',
|
|
317
|
-
'https://127.0.0.1',
|
|
318
|
-
'http://[::1]',
|
|
319
|
-
'https://[::1]',
|
|
320
|
-
];
|
|
321
|
-
const isLocalhost = LOCALHOST_ORIGINS.some(
|
|
322
|
-
allowed => origin === allowed || origin.startsWith(allowed + ':')
|
|
323
|
-
);
|
|
324
|
-
if (!isLocalhost) {
|
|
325
|
-
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
326
|
-
socket.destroy();
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Complete WebSocket handshake
|
|
332
|
-
const key = req.headers['sec-websocket-key'];
|
|
333
|
-
const acceptKey = getCrypto()
|
|
334
|
-
.createHash('sha1')
|
|
335
|
-
.update(key + WS_GUID)
|
|
336
|
-
.digest('base64');
|
|
337
|
-
|
|
338
|
-
const responseHeaders = [
|
|
339
|
-
'HTTP/1.1 101 Switching Protocols',
|
|
340
|
-
'Upgrade: websocket',
|
|
341
|
-
'Connection: Upgrade',
|
|
342
|
-
`Sec-WebSocket-Accept: ${acceptKey}`,
|
|
343
|
-
'',
|
|
344
|
-
'',
|
|
345
|
-
].join('\r\n');
|
|
346
|
-
|
|
347
|
-
socket.write(responseHeaders);
|
|
348
|
-
|
|
349
|
-
// Create session
|
|
350
|
-
const sessionId = this.getSessionId(req);
|
|
351
|
-
this.createSession(sessionId, socket);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Get or generate session ID from request
|
|
356
|
-
*/
|
|
357
|
-
getSessionId(req) {
|
|
358
|
-
// Check for session ID in query string
|
|
359
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
360
|
-
const sessionId = url.searchParams.get('session_id');
|
|
361
|
-
|
|
362
|
-
if (sessionId && this.sessions.has(sessionId)) {
|
|
363
|
-
return sessionId; // Resume existing session
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Generate new session ID
|
|
367
|
-
return getCrypto().randomBytes(16).toString('hex');
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Create a new dashboard session
|
|
372
|
-
*/
|
|
373
|
-
createSession(sessionId, socket) {
|
|
374
|
-
// Check if resuming existing session
|
|
375
|
-
let session = this.sessions.get(sessionId);
|
|
376
|
-
const isResume = !!session;
|
|
377
|
-
|
|
378
|
-
if (!session) {
|
|
379
|
-
session = new DashboardSession(sessionId, socket, this.projectRoot);
|
|
380
|
-
this.sessions.set(sessionId, session);
|
|
381
|
-
} else {
|
|
382
|
-
// Clean up old socket before replacing
|
|
383
|
-
if (session.ws && session.ws !== socket) {
|
|
384
|
-
session.ws.removeAllListeners();
|
|
385
|
-
session.ws.destroy();
|
|
386
|
-
}
|
|
387
|
-
session.ws = socket;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
console.log(`[Session ${sessionId}] ${isResume ? 'Resumed' : 'Connected'}`);
|
|
391
|
-
|
|
392
|
-
// Send initial state
|
|
393
|
-
session.send(
|
|
394
|
-
getProtocol().createSessionState(sessionId, 'connected', {
|
|
395
|
-
resumed: isResume,
|
|
396
|
-
messageCount: session.messages.length,
|
|
397
|
-
project: require('path').basename(this.projectRoot),
|
|
398
|
-
})
|
|
399
|
-
);
|
|
400
|
-
|
|
401
|
-
// Send initial git status
|
|
402
|
-
this.sendGitStatus(session);
|
|
403
|
-
|
|
404
|
-
// Send project status (stories/epics)
|
|
405
|
-
this.sendStatusUpdate(session);
|
|
406
|
-
|
|
407
|
-
// Send team metrics
|
|
408
|
-
this.sendTeamMetrics(session);
|
|
409
|
-
|
|
410
|
-
// Send session list with sync info
|
|
411
|
-
this.sendSessionList(session);
|
|
412
|
-
|
|
413
|
-
// Send initial automation list and inbox
|
|
414
|
-
this.sendAutomationList(session);
|
|
415
|
-
this.sendInboxList(session);
|
|
416
|
-
|
|
417
|
-
// Handle incoming messages
|
|
418
|
-
let buffer = Buffer.alloc(0);
|
|
419
|
-
|
|
420
|
-
socket.on('data', data => {
|
|
421
|
-
buffer = Buffer.concat([buffer, data]);
|
|
422
|
-
|
|
423
|
-
// Process complete WebSocket frames
|
|
424
|
-
while (buffer.length >= 2) {
|
|
425
|
-
const frame = decodeWebSocketFrame(buffer);
|
|
426
|
-
if (!frame) break;
|
|
427
|
-
|
|
428
|
-
buffer = buffer.slice(frame.totalLength);
|
|
429
|
-
|
|
430
|
-
if (frame.opcode === 0x8) {
|
|
431
|
-
// Close frame
|
|
432
|
-
socket.end();
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (frame.opcode === 0x9) {
|
|
437
|
-
// Ping - send pong
|
|
438
|
-
socket.write(encodeWebSocketFrame('', 0x0a));
|
|
439
|
-
continue;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (frame.opcode === 0x1 || frame.opcode === 0x2) {
|
|
443
|
-
// Text or binary frame
|
|
444
|
-
this.handleMessage(session, frame.payload.toString());
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
socket.on('close', () => {
|
|
450
|
-
console.log(`[Session ${sessionId}] Disconnected`);
|
|
451
|
-
// Keep session for potential reconnect
|
|
452
|
-
session.ws = null;
|
|
453
|
-
session.state = 'disconnected';
|
|
454
|
-
this.emit('session:disconnected', sessionId);
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
socket.on('error', err => {
|
|
458
|
-
console.error(`[Session ${sessionId}] Socket error:`, err.message);
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
this.emit('session:connected', sessionId, session);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Handle incoming message from dashboard
|
|
466
|
-
*/
|
|
467
|
-
handleMessage(session, data) {
|
|
468
|
-
// Rate limit incoming messages
|
|
469
|
-
if (!session.checkRateLimit()) {
|
|
470
|
-
session.send(
|
|
471
|
-
getProtocol().createError('RATE_LIMITED', 'Too many messages, please slow down')
|
|
472
|
-
);
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
const message = getProtocol().parseInboundMessage(data);
|
|
477
|
-
if (!message) {
|
|
478
|
-
session.send(getProtocol().createError('INVALID_MESSAGE', 'Failed to parse message'));
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
console.log(`[Session ${session.id}] Received: ${message.type}`);
|
|
483
|
-
|
|
484
|
-
switch (message.type) {
|
|
485
|
-
case getProtocol().InboundMessageType.MESSAGE:
|
|
486
|
-
this.handleUserMessage(session, message);
|
|
487
|
-
break;
|
|
488
|
-
|
|
489
|
-
case getProtocol().InboundMessageType.CANCEL:
|
|
490
|
-
this.handleCancel(session);
|
|
491
|
-
break;
|
|
492
|
-
|
|
493
|
-
case getProtocol().InboundMessageType.REFRESH:
|
|
494
|
-
this.handleRefresh(session, message);
|
|
495
|
-
break;
|
|
496
|
-
|
|
497
|
-
case getProtocol().InboundMessageType.GIT_STAGE:
|
|
498
|
-
case getProtocol().InboundMessageType.GIT_UNSTAGE:
|
|
499
|
-
case getProtocol().InboundMessageType.GIT_REVERT:
|
|
500
|
-
case getProtocol().InboundMessageType.GIT_COMMIT:
|
|
501
|
-
this.handleGitAction(session, message);
|
|
502
|
-
break;
|
|
503
|
-
|
|
504
|
-
case getProtocol().InboundMessageType.GIT_DIFF_REQUEST:
|
|
505
|
-
this.handleDiffRequest(session, message);
|
|
506
|
-
break;
|
|
507
|
-
|
|
508
|
-
case getProtocol().InboundMessageType.SESSION_CLOSE:
|
|
509
|
-
this.closeSession(session.id);
|
|
510
|
-
break;
|
|
511
|
-
|
|
512
|
-
case getProtocol().InboundMessageType.TERMINAL_SPAWN:
|
|
513
|
-
this.handleTerminalSpawn(session, message);
|
|
514
|
-
break;
|
|
515
|
-
|
|
516
|
-
case getProtocol().InboundMessageType.TERMINAL_INPUT:
|
|
517
|
-
this.handleTerminalInput(session, message);
|
|
518
|
-
break;
|
|
519
|
-
|
|
520
|
-
case getProtocol().InboundMessageType.TERMINAL_RESIZE:
|
|
521
|
-
this.handleTerminalResize(session, message);
|
|
522
|
-
break;
|
|
523
|
-
|
|
524
|
-
case getProtocol().InboundMessageType.TERMINAL_CLOSE:
|
|
525
|
-
this.handleTerminalClose(session, message);
|
|
526
|
-
break;
|
|
527
|
-
|
|
528
|
-
case getProtocol().InboundMessageType.AUTOMATION_LIST_REQUEST:
|
|
529
|
-
this.sendAutomationList(session);
|
|
530
|
-
break;
|
|
531
|
-
|
|
532
|
-
case getProtocol().InboundMessageType.AUTOMATION_RUN:
|
|
533
|
-
this.handleAutomationRun(session, message);
|
|
534
|
-
break;
|
|
535
|
-
|
|
536
|
-
case getProtocol().InboundMessageType.AUTOMATION_STOP:
|
|
537
|
-
this.handleAutomationStop(session, message);
|
|
538
|
-
break;
|
|
539
|
-
|
|
540
|
-
case getProtocol().InboundMessageType.INBOX_LIST_REQUEST:
|
|
541
|
-
this.sendInboxList(session);
|
|
542
|
-
break;
|
|
543
|
-
|
|
544
|
-
case getProtocol().InboundMessageType.INBOX_ACTION:
|
|
545
|
-
this.handleInboxAction(session, message);
|
|
546
|
-
break;
|
|
547
|
-
|
|
548
|
-
case getProtocol().InboundMessageType.OPEN_FILE:
|
|
549
|
-
this.handleOpenFile(session, message);
|
|
550
|
-
break;
|
|
551
|
-
|
|
552
|
-
default:
|
|
553
|
-
console.log(`[Session ${session.id}] Unhandled message type: ${message.type}`);
|
|
554
|
-
this.emit('message', session, message);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Handle user message - forward to Claude
|
|
560
|
-
*/
|
|
561
|
-
handleUserMessage(session, message) {
|
|
562
|
-
const content = message.content?.trim();
|
|
563
|
-
if (!content) {
|
|
564
|
-
session.send(getProtocol().createError('EMPTY_MESSAGE', 'Message content is empty'));
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Add to conversation history
|
|
569
|
-
session.addMessage('user', content);
|
|
570
|
-
|
|
571
|
-
// Update state
|
|
572
|
-
session.setState('thinking');
|
|
573
|
-
|
|
574
|
-
// Emit for external handling (Claude API integration)
|
|
575
|
-
this.emit('user:message', session, content);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* Handle cancel request
|
|
580
|
-
*/
|
|
581
|
-
handleCancel(session) {
|
|
582
|
-
session.setState('idle');
|
|
583
|
-
session.send(getProtocol().createNotification('info', 'Cancelled', 'Operation cancelled'));
|
|
584
|
-
this.emit('user:cancel', session);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Handle refresh request
|
|
589
|
-
*/
|
|
590
|
-
handleRefresh(session, message) {
|
|
591
|
-
const what = message.what || 'all';
|
|
592
|
-
|
|
593
|
-
switch (what) {
|
|
594
|
-
case 'git':
|
|
595
|
-
this.sendGitStatus(session);
|
|
596
|
-
break;
|
|
597
|
-
case 'tasks':
|
|
598
|
-
this.emit('refresh:tasks', session);
|
|
599
|
-
break;
|
|
600
|
-
case 'status':
|
|
601
|
-
this.sendStatusUpdate(session);
|
|
602
|
-
this.emit('refresh:status', session);
|
|
603
|
-
break;
|
|
604
|
-
case 'sessions':
|
|
605
|
-
this.sendSessionList(session);
|
|
606
|
-
break;
|
|
607
|
-
case 'automations':
|
|
608
|
-
this.sendAutomationList(session);
|
|
609
|
-
break;
|
|
610
|
-
case 'inbox':
|
|
611
|
-
this.sendInboxList(session);
|
|
612
|
-
break;
|
|
613
|
-
case 'team_metrics':
|
|
614
|
-
this.sendTeamMetrics(session);
|
|
615
|
-
break;
|
|
616
|
-
default:
|
|
617
|
-
this.sendGitStatus(session);
|
|
618
|
-
this.sendStatusUpdate(session);
|
|
619
|
-
this.sendTeamMetrics(session);
|
|
620
|
-
this.sendSessionList(session);
|
|
621
|
-
this.sendAutomationList(session);
|
|
622
|
-
this.sendInboxList(session);
|
|
623
|
-
this.emit('refresh:all', session);
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// ==========================================================================
|
|
628
|
-
// Git Handlers (delegating to dashboard-git.js)
|
|
629
|
-
// ==========================================================================
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* Handle git actions
|
|
633
|
-
*/
|
|
634
|
-
handleGitAction(session, message) {
|
|
635
|
-
const { type, files, message: commitMessage } = message;
|
|
636
|
-
|
|
637
|
-
try {
|
|
638
|
-
handleGitAction(type, this.projectRoot, { files, commitMessage }, getProtocol());
|
|
639
|
-
|
|
640
|
-
// Send updated git status
|
|
641
|
-
this.sendGitStatus(session);
|
|
642
|
-
session.send(
|
|
643
|
-
getProtocol().createNotification('success', 'Git', `${type.replace('git_', '')} completed`)
|
|
644
|
-
);
|
|
645
|
-
} catch (error) {
|
|
646
|
-
console.error('[Git Error]', error.message);
|
|
647
|
-
session.send(getProtocol().createError('GIT_ERROR', error.message || 'Git operation failed'));
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
/**
|
|
652
|
-
* Send git status to session
|
|
653
|
-
*/
|
|
654
|
-
sendGitStatus(session) {
|
|
655
|
-
try {
|
|
656
|
-
const status = getGitStatus(this.projectRoot);
|
|
657
|
-
session.send({
|
|
658
|
-
type: getProtocol().OutboundMessageType.GIT_STATUS,
|
|
659
|
-
...status,
|
|
660
|
-
timestamp: new Date().toISOString(),
|
|
661
|
-
});
|
|
662
|
-
} catch (error) {
|
|
663
|
-
console.error('[Git Status Error]', error.message);
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/**
|
|
668
|
-
* Handle diff request for a file
|
|
669
|
-
*/
|
|
670
|
-
handleDiffRequest(session, message) {
|
|
671
|
-
const { path: filePath, staged } = message;
|
|
672
|
-
|
|
673
|
-
if (!filePath) {
|
|
674
|
-
session.send(getProtocol().createError('INVALID_REQUEST', 'File path is required'));
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
try {
|
|
679
|
-
const diff = getFileDiff(filePath, this.projectRoot, staged);
|
|
680
|
-
const stats = parseDiffStats(diff);
|
|
681
|
-
|
|
682
|
-
session.send(
|
|
683
|
-
getProtocol().createGitDiff(filePath, diff, {
|
|
684
|
-
additions: stats.additions,
|
|
685
|
-
deletions: stats.deletions,
|
|
686
|
-
staged: !!staged,
|
|
687
|
-
})
|
|
688
|
-
);
|
|
689
|
-
} catch (error) {
|
|
690
|
-
console.error('[Diff Error]', error.message);
|
|
691
|
-
session.send(getProtocol().createError('DIFF_ERROR', 'Failed to get diff'));
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// ==========================================================================
|
|
696
|
-
// Status/Metrics Handlers (delegating to dashboard-status.js)
|
|
697
|
-
// ==========================================================================
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* Send project status update (stories/epics summary) to session
|
|
701
|
-
*/
|
|
702
|
-
sendStatusUpdate(session) {
|
|
703
|
-
try {
|
|
704
|
-
const summary = buildStatusSummary(this.projectRoot);
|
|
705
|
-
if (summary) {
|
|
706
|
-
session.send(getProtocol().createStatusUpdate(summary));
|
|
707
|
-
}
|
|
708
|
-
} catch (error) {
|
|
709
|
-
console.error('[Status Update Error]', error.message);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* Initialize listener for team metrics events
|
|
715
|
-
*/
|
|
716
|
-
_initTeamMetricsListener() {
|
|
717
|
-
try {
|
|
718
|
-
const { teamMetricsEmitter } = require('../scripts/lib/team-events');
|
|
719
|
-
this._teamMetricsListener = () => {
|
|
720
|
-
this.broadcastTeamMetrics();
|
|
721
|
-
};
|
|
722
|
-
teamMetricsEmitter.on('metrics_saved', this._teamMetricsListener);
|
|
723
|
-
} catch (e) {
|
|
724
|
-
// team-events not available - non-critical
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
/**
|
|
729
|
-
* Send team metrics to a single session
|
|
730
|
-
*/
|
|
731
|
-
sendTeamMetrics(session) {
|
|
732
|
-
const traces = readTeamMetrics(this.projectRoot);
|
|
733
|
-
for (const [traceId, metrics] of Object.entries(traces)) {
|
|
734
|
-
session.send(getProtocol().createTeamMetrics(traceId, metrics));
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* Broadcast team metrics to all connected clients
|
|
740
|
-
*/
|
|
741
|
-
broadcastTeamMetrics() {
|
|
742
|
-
const traces = readTeamMetrics(this.projectRoot);
|
|
743
|
-
for (const [traceId, metrics] of Object.entries(traces)) {
|
|
744
|
-
this.broadcast(getProtocol().createTeamMetrics(traceId, metrics));
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
/**
|
|
749
|
-
* Send session list with sync status to dashboard
|
|
750
|
-
*/
|
|
751
|
-
sendSessionList(session) {
|
|
752
|
-
const sessions = [];
|
|
753
|
-
|
|
754
|
-
for (const [id, s] of this.sessions) {
|
|
755
|
-
const entry = {
|
|
756
|
-
id,
|
|
757
|
-
name: s.metadata.name || id,
|
|
758
|
-
type: s.metadata.type || 'local',
|
|
759
|
-
status: s.state === 'connected' ? 'active' : s.state === 'disconnected' ? 'idle' : s.state,
|
|
760
|
-
branch: null,
|
|
761
|
-
messageCount: s.messages.length,
|
|
762
|
-
lastActivity: s.lastActivity.toISOString(),
|
|
763
|
-
syncStatus: 'offline',
|
|
764
|
-
ahead: 0,
|
|
765
|
-
behind: 0,
|
|
766
|
-
};
|
|
767
|
-
|
|
768
|
-
// Get branch and sync status via git
|
|
769
|
-
try {
|
|
770
|
-
const cwd = s.metadata.worktreePath || this.projectRoot;
|
|
771
|
-
entry.branch = getChildProcess()
|
|
772
|
-
.execFileSync('git', ['branch', '--show-current'], {
|
|
773
|
-
cwd,
|
|
774
|
-
encoding: 'utf8',
|
|
775
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
776
|
-
})
|
|
777
|
-
.trim();
|
|
778
|
-
|
|
779
|
-
// Get ahead/behind counts relative to upstream
|
|
780
|
-
try {
|
|
781
|
-
const counts = getChildProcess()
|
|
782
|
-
.execFileSync('git', ['rev-list', '--left-right', '--count', 'HEAD...@{u}'], {
|
|
783
|
-
cwd,
|
|
784
|
-
encoding: 'utf8',
|
|
785
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
786
|
-
})
|
|
787
|
-
.trim();
|
|
788
|
-
const [ahead, behind] = counts.split(/\s+/).map(Number);
|
|
789
|
-
entry.ahead = ahead || 0;
|
|
790
|
-
entry.behind = behind || 0;
|
|
791
|
-
|
|
792
|
-
if (ahead > 0 && behind > 0) {
|
|
793
|
-
entry.syncStatus = 'diverged';
|
|
794
|
-
} else if (ahead > 0) {
|
|
795
|
-
entry.syncStatus = 'ahead';
|
|
796
|
-
} else if (behind > 0) {
|
|
797
|
-
entry.syncStatus = 'behind';
|
|
798
|
-
} else {
|
|
799
|
-
entry.syncStatus = 'synced';
|
|
800
|
-
}
|
|
801
|
-
} catch {
|
|
802
|
-
// No upstream configured
|
|
803
|
-
entry.syncStatus = 'synced';
|
|
804
|
-
}
|
|
805
|
-
} catch {
|
|
806
|
-
entry.syncStatus = 'offline';
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
sessions.push(entry);
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
session.send(getProtocol().createSessionList(sessions));
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
/**
|
|
816
|
-
* Handle open file in editor request
|
|
817
|
-
*/
|
|
818
|
-
handleOpenFile(session, message) {
|
|
819
|
-
const { path: filePath, line } = message;
|
|
820
|
-
|
|
821
|
-
if (!filePath || typeof filePath !== 'string') {
|
|
822
|
-
session.send(getProtocol().createError('INVALID_REQUEST', 'File path is required'));
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// Validate the path stays within project root
|
|
827
|
-
const pathResult = getValidatePaths().validatePath(filePath, this.projectRoot, {
|
|
828
|
-
allowSymlinks: true,
|
|
829
|
-
});
|
|
830
|
-
if (!pathResult.ok) {
|
|
831
|
-
session.send(getProtocol().createError('OPEN_FILE_ERROR', 'File path outside project'));
|
|
832
|
-
return;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
const fullPath = pathResult.resolvedPath;
|
|
836
|
-
|
|
837
|
-
// Detect editor from environment
|
|
838
|
-
const editor = process.env.VISUAL || process.env.EDITOR || 'code';
|
|
839
|
-
const editorBase = require('path').basename(editor).toLowerCase();
|
|
840
|
-
|
|
841
|
-
try {
|
|
842
|
-
const lineNum = Number.isFinite(line) && line > 0 ? line : null;
|
|
843
|
-
|
|
844
|
-
switch (editorBase) {
|
|
845
|
-
case 'code':
|
|
846
|
-
case 'cursor':
|
|
847
|
-
case 'windsurf': {
|
|
848
|
-
const gotoArg = lineNum ? `${fullPath}:${lineNum}` : fullPath;
|
|
849
|
-
getChildProcess()
|
|
850
|
-
.spawn(editor, ['--goto', gotoArg], { detached: true, stdio: 'ignore' })
|
|
851
|
-
.unref();
|
|
852
|
-
break;
|
|
853
|
-
}
|
|
854
|
-
case 'subl':
|
|
855
|
-
case 'sublime_text': {
|
|
856
|
-
const sublArg = lineNum ? `${fullPath}:${lineNum}` : fullPath;
|
|
857
|
-
getChildProcess().spawn(editor, [sublArg], { detached: true, stdio: 'ignore' }).unref();
|
|
858
|
-
break;
|
|
859
|
-
}
|
|
860
|
-
default: {
|
|
861
|
-
// Generic: just open the file
|
|
862
|
-
getChildProcess().spawn(editor, [fullPath], { detached: true, stdio: 'ignore' }).unref();
|
|
863
|
-
break;
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
session.send(
|
|
868
|
-
getProtocol().createNotification(
|
|
869
|
-
'info',
|
|
870
|
-
'Editor',
|
|
871
|
-
`Opened ${require('path').basename(fullPath)}`
|
|
872
|
-
)
|
|
873
|
-
);
|
|
874
|
-
} catch (error) {
|
|
875
|
-
console.error('[Open File Error]', error.message);
|
|
876
|
-
session.send(
|
|
877
|
-
getProtocol().createError('OPEN_FILE_ERROR', `Failed to open file: ${error.message}`)
|
|
878
|
-
);
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// ==========================================================================
|
|
883
|
-
// Terminal Handlers (delegating to dashboard-terminal.js)
|
|
884
|
-
// ==========================================================================
|
|
885
|
-
|
|
886
|
-
/**
|
|
887
|
-
* Handle terminal spawn request
|
|
888
|
-
*/
|
|
889
|
-
handleTerminalSpawn(session, message) {
|
|
890
|
-
const { cols, rows, cwd } = message;
|
|
891
|
-
|
|
892
|
-
// Validate cwd stays within project root
|
|
893
|
-
let safeCwd = this.projectRoot;
|
|
894
|
-
if (cwd) {
|
|
895
|
-
const cwdResult = getValidatePaths().validatePath(cwd, this.projectRoot, {
|
|
896
|
-
allowSymlinks: true,
|
|
897
|
-
});
|
|
898
|
-
if (!cwdResult.ok) {
|
|
899
|
-
session.send(
|
|
900
|
-
getProtocol().createError(
|
|
901
|
-
'TERMINAL_ERROR',
|
|
902
|
-
'Working directory must be within project root'
|
|
903
|
-
)
|
|
904
|
-
);
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
safeCwd = cwdResult.resolvedPath;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
const terminalId = this.terminalManager.createTerminal(session, {
|
|
911
|
-
cols: cols || 80,
|
|
912
|
-
rows: rows || 24,
|
|
913
|
-
cwd: safeCwd,
|
|
914
|
-
});
|
|
915
|
-
|
|
916
|
-
if (terminalId) {
|
|
917
|
-
session.send({
|
|
918
|
-
type: 'terminal_spawned',
|
|
919
|
-
terminalId,
|
|
920
|
-
timestamp: new Date().toISOString(),
|
|
921
|
-
});
|
|
922
|
-
} else {
|
|
923
|
-
session.send(getProtocol().createError('TERMINAL_ERROR', 'Failed to spawn terminal'));
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
/**
|
|
928
|
-
* Handle terminal input
|
|
929
|
-
*/
|
|
930
|
-
handleTerminalInput(session, message) {
|
|
931
|
-
const { terminalId, data } = message;
|
|
932
|
-
|
|
933
|
-
if (!terminalId || !data) {
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
this.terminalManager.writeToTerminal(terminalId, data);
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
/**
|
|
941
|
-
* Handle terminal resize
|
|
942
|
-
*/
|
|
943
|
-
handleTerminalResize(session, message) {
|
|
944
|
-
const { terminalId, cols, rows } = message;
|
|
945
|
-
|
|
946
|
-
if (!terminalId || !cols || !rows) {
|
|
947
|
-
return;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
this.terminalManager.resizeTerminal(terminalId, cols, rows);
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
/**
|
|
954
|
-
* Handle terminal close
|
|
955
|
-
*/
|
|
956
|
-
handleTerminalClose(session, message) {
|
|
957
|
-
const { terminalId } = message;
|
|
958
|
-
|
|
959
|
-
if (!terminalId) {
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
this.terminalManager.closeTerminal(terminalId);
|
|
964
|
-
session.send(getProtocol().createNotification('info', 'Terminal', 'Terminal closed'));
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
// ==========================================================================
|
|
968
|
-
// Automation Handlers (delegating to dashboard-automations.js)
|
|
969
|
-
// ==========================================================================
|
|
970
|
-
|
|
971
|
-
/**
|
|
972
|
-
* Send automation list to session
|
|
973
|
-
*/
|
|
974
|
-
sendAutomationList(session) {
|
|
975
|
-
if (!this._automationRegistry) {
|
|
976
|
-
session.send(getProtocol().createAutomationList([]));
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
try {
|
|
981
|
-
const automations = this._automationRegistry.list() || [];
|
|
982
|
-
const enriched = enrichAutomationList(
|
|
983
|
-
automations,
|
|
984
|
-
this._runningAutomations,
|
|
985
|
-
this._automationRegistry
|
|
986
|
-
);
|
|
987
|
-
session.send(getProtocol().createAutomationList(enriched));
|
|
988
|
-
} catch (error) {
|
|
989
|
-
console.error('[Automations] List error:', error.message);
|
|
990
|
-
session.send(getProtocol().createAutomationList([]));
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
/**
|
|
995
|
-
* Handle automation run request
|
|
996
|
-
*/
|
|
997
|
-
async handleAutomationRun(session, message) {
|
|
998
|
-
const { id: automationId } = message;
|
|
999
|
-
|
|
1000
|
-
if (!automationId) {
|
|
1001
|
-
session.send(getProtocol().createError('INVALID_REQUEST', 'Automation ID is required'));
|
|
1002
|
-
return;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
if (!this._automationRunner) {
|
|
1006
|
-
session.send(
|
|
1007
|
-
getProtocol().createError('AUTOMATION_ERROR', 'Automation runner not initialized')
|
|
1008
|
-
);
|
|
1009
|
-
return;
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
try {
|
|
1013
|
-
// Check if already running
|
|
1014
|
-
if (this._runningAutomations.has(automationId)) {
|
|
1015
|
-
session.send(
|
|
1016
|
-
getProtocol().createNotification(
|
|
1017
|
-
'warning',
|
|
1018
|
-
'Automation',
|
|
1019
|
-
`${automationId} is already running`
|
|
1020
|
-
)
|
|
1021
|
-
);
|
|
1022
|
-
return;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// Mark as running BEFORE the async call to prevent duplicate execution
|
|
1026
|
-
this._runningAutomations.set(automationId, { startTime: Date.now() });
|
|
1027
|
-
|
|
1028
|
-
session.send(
|
|
1029
|
-
getProtocol().createNotification('info', 'Automation', `Starting ${automationId}...`)
|
|
1030
|
-
);
|
|
1031
|
-
|
|
1032
|
-
// Run the automation (async)
|
|
1033
|
-
const result = await this._automationRunner.run(automationId);
|
|
1034
|
-
|
|
1035
|
-
// Send result notification
|
|
1036
|
-
if (result.success) {
|
|
1037
|
-
session.send(
|
|
1038
|
-
getProtocol().createNotification(
|
|
1039
|
-
'success',
|
|
1040
|
-
'Automation',
|
|
1041
|
-
`${automationId} completed successfully`
|
|
1042
|
-
)
|
|
1043
|
-
);
|
|
1044
|
-
} else {
|
|
1045
|
-
session.send(
|
|
1046
|
-
getProtocol().createNotification(
|
|
1047
|
-
'error',
|
|
1048
|
-
'Automation',
|
|
1049
|
-
`${automationId} failed: ${result.error}`
|
|
1050
|
-
)
|
|
1051
|
-
);
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
// Send final status
|
|
1055
|
-
session.send(
|
|
1056
|
-
getProtocol().createAutomationStatus(
|
|
1057
|
-
automationId,
|
|
1058
|
-
result.success ? 'idle' : 'error',
|
|
1059
|
-
result
|
|
1060
|
-
)
|
|
1061
|
-
);
|
|
1062
|
-
|
|
1063
|
-
// Refresh the list
|
|
1064
|
-
this.sendAutomationList(session);
|
|
1065
|
-
} catch (error) {
|
|
1066
|
-
console.error('[Automation Error]', error.message);
|
|
1067
|
-
session.send(getProtocol().createError('AUTOMATION_ERROR', 'Automation execution failed'));
|
|
1068
|
-
session.send(
|
|
1069
|
-
getProtocol().createAutomationStatus(automationId, 'error', { error: 'Execution failed' })
|
|
1070
|
-
);
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/**
|
|
1075
|
-
* Handle automation stop request
|
|
1076
|
-
*/
|
|
1077
|
-
handleAutomationStop(session, message) {
|
|
1078
|
-
const { id: automationId } = message;
|
|
1079
|
-
|
|
1080
|
-
if (!automationId) {
|
|
1081
|
-
session.send(getProtocol().createError('INVALID_REQUEST', 'Automation ID is required'));
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
// Cancel via runner
|
|
1086
|
-
if (this._automationRunner) {
|
|
1087
|
-
this._automationRunner.cancelAll(); // TODO: Add single automation cancel
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
this._runningAutomations.delete(automationId);
|
|
1091
|
-
session.send(getProtocol().createAutomationStatus(automationId, 'idle'));
|
|
1092
|
-
session.send(getProtocol().createNotification('info', 'Automation', `${automationId} stopped`));
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
// ==========================================================================
|
|
1096
|
-
// Inbox Handlers (delegating to dashboard-inbox.js)
|
|
1097
|
-
// ==========================================================================
|
|
1098
|
-
|
|
1099
|
-
/**
|
|
1100
|
-
* Send inbox list to session
|
|
1101
|
-
*/
|
|
1102
|
-
sendInboxList(session) {
|
|
1103
|
-
const items = getSortedInboxItems(this._inbox);
|
|
1104
|
-
session.send(getProtocol().createInboxList(items));
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
/**
|
|
1108
|
-
* Handle inbox action (accept, dismiss, mark read)
|
|
1109
|
-
*/
|
|
1110
|
-
handleInboxAction(session, message) {
|
|
1111
|
-
const { id: itemId, action } = message;
|
|
1112
|
-
|
|
1113
|
-
if (!itemId) {
|
|
1114
|
-
session.send(getProtocol().createError('INVALID_REQUEST', 'Item ID is required'));
|
|
1115
|
-
return;
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
const result = handleInboxAction(this._inbox, itemId, action);
|
|
1119
|
-
|
|
1120
|
-
if (!result.success) {
|
|
1121
|
-
const errorCode = result.error.includes('not found') ? 'NOT_FOUND' : 'INVALID_ACTION';
|
|
1122
|
-
session.send(getProtocol().createError(errorCode, result.error));
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
if (result.notification) {
|
|
1127
|
-
session.send(
|
|
1128
|
-
getProtocol().createNotification(
|
|
1129
|
-
result.notification.level,
|
|
1130
|
-
'Inbox',
|
|
1131
|
-
result.notification.message
|
|
1132
|
-
)
|
|
1133
|
-
);
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
// Send updated inbox list
|
|
1137
|
-
this.sendInboxList(session);
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
// ==========================================================================
|
|
1141
|
-
// Session Lifecycle
|
|
1142
|
-
// ==========================================================================
|
|
1143
|
-
|
|
1144
|
-
/**
|
|
1145
|
-
* Cleanup expired sessions
|
|
1146
|
-
*/
|
|
1147
|
-
_cleanupExpiredSessions() {
|
|
1148
|
-
// Collect expired IDs first to avoid mutating Map during iteration
|
|
1149
|
-
const expiredIds = [];
|
|
1150
|
-
for (const [sessionId, session] of this.sessions) {
|
|
1151
|
-
if (session.isExpired()) {
|
|
1152
|
-
expiredIds.push(sessionId);
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
for (const sessionId of expiredIds) {
|
|
1156
|
-
console.log(`[Session ${sessionId}] Expired (idle > ${SESSION_TIMEOUT_MS / 3600000}h)`);
|
|
1157
|
-
this.closeSession(sessionId);
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
/**
|
|
1162
|
-
* Close a session
|
|
1163
|
-
*/
|
|
1164
|
-
closeSession(sessionId) {
|
|
1165
|
-
const session = this.sessions.get(sessionId);
|
|
1166
|
-
if (session) {
|
|
1167
|
-
// Close all terminals for this session
|
|
1168
|
-
this.terminalManager.closeSessionTerminals(sessionId);
|
|
1169
|
-
|
|
1170
|
-
if (session.ws) {
|
|
1171
|
-
session.ws.end();
|
|
1172
|
-
}
|
|
1173
|
-
this.sessions.delete(sessionId);
|
|
1174
|
-
console.log(`[Session ${sessionId}] Closed`);
|
|
1175
|
-
this.emit('session:closed', sessionId);
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
/**
|
|
1180
|
-
* Get session by ID
|
|
1181
|
-
*/
|
|
1182
|
-
getSession(sessionId) {
|
|
1183
|
-
return this.sessions.get(sessionId);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
/**
|
|
1187
|
-
* Broadcast message to all sessions
|
|
1188
|
-
*/
|
|
1189
|
-
broadcast(message) {
|
|
1190
|
-
for (const session of this.sessions.values()) {
|
|
1191
|
-
if (session.ws) {
|
|
1192
|
-
session.send(message);
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
/**
|
|
1198
|
-
* Stop the server
|
|
1199
|
-
*/
|
|
1200
|
-
stop() {
|
|
1201
|
-
return new Promise(resolve => {
|
|
1202
|
-
// Remove team metrics listener to prevent leak
|
|
1203
|
-
if (this._teamMetricsListener) {
|
|
1204
|
-
try {
|
|
1205
|
-
const { teamMetricsEmitter } = require('../scripts/lib/team-events');
|
|
1206
|
-
teamMetricsEmitter.removeListener('metrics_saved', this._teamMetricsListener);
|
|
1207
|
-
} catch (e) {
|
|
1208
|
-
// Ignore if module not available
|
|
1209
|
-
}
|
|
1210
|
-
this._teamMetricsListener = null;
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
// Clear cleanup interval
|
|
1214
|
-
if (this._cleanupInterval) {
|
|
1215
|
-
clearInterval(this._cleanupInterval);
|
|
1216
|
-
this._cleanupInterval = null;
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
// Close all sessions
|
|
1220
|
-
for (const session of this.sessions.values()) {
|
|
1221
|
-
if (session.ws) {
|
|
1222
|
-
session.ws.end();
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
this.sessions.clear();
|
|
1226
|
-
|
|
1227
|
-
// Close HTTP server
|
|
1228
|
-
if (this.httpServer) {
|
|
1229
|
-
this.httpServer.close(() => {
|
|
1230
|
-
console.log('[AgileFlow Dashboard Server] Stopped');
|
|
1231
|
-
resolve();
|
|
1232
|
-
});
|
|
1233
|
-
} else {
|
|
1234
|
-
resolve();
|
|
1235
|
-
}
|
|
1236
|
-
});
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
// ============================================================================
|
|
1241
|
-
// Factory Functions
|
|
1242
|
-
// ============================================================================
|
|
1243
|
-
|
|
1244
|
-
/**
|
|
1245
|
-
* Create a dashboard server instance
|
|
1246
|
-
* @param {Object} [options={}] - Server options
|
|
1247
|
-
* @param {number} [options.port=8765] - Port to listen on
|
|
1248
|
-
* @param {string} [options.host='0.0.0.0'] - Host to bind to
|
|
1249
|
-
* @param {string} [options.projectRoot] - Project root directory
|
|
1250
|
-
* @param {string} [options.apiKey] - API key for authentication
|
|
1251
|
-
* @param {boolean} [options.requireAuth=false] - Require API key
|
|
1252
|
-
* @returns {DashboardServer}
|
|
1253
|
-
*/
|
|
1254
|
-
function createDashboardServer(options = {}) {
|
|
1255
|
-
return new DashboardServer(options);
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
/**
|
|
1259
|
-
* Start a dashboard server
|
|
1260
|
-
* @param {DashboardServer} server - Server instance
|
|
1261
|
-
* @returns {Promise<{ url: string, wsUrl: string }>}
|
|
1262
|
-
*/
|
|
1263
|
-
async function startDashboardServer(server) {
|
|
1264
|
-
return server.start();
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
/**
|
|
1268
|
-
* Stop a dashboard server
|
|
1269
|
-
* @param {DashboardServer} server - Server instance
|
|
1270
|
-
* @returns {Promise<void>}
|
|
1271
|
-
*/
|
|
1272
|
-
async function stopDashboardServer(server) {
|
|
1273
|
-
return server.stop();
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
// ============================================================================
|
|
1277
|
-
// Exports (backward-compatible - re-exports from extracted modules)
|
|
1278
|
-
// ============================================================================
|
|
1279
|
-
|
|
1280
|
-
module.exports = {
|
|
1281
|
-
DashboardServer,
|
|
1282
|
-
DashboardSession,
|
|
1283
|
-
TerminalInstance,
|
|
1284
|
-
TerminalManager,
|
|
1285
|
-
createDashboardServer,
|
|
1286
|
-
startDashboardServer,
|
|
1287
|
-
stopDashboardServer,
|
|
1288
|
-
DEFAULT_PORT,
|
|
1289
|
-
DEFAULT_HOST,
|
|
1290
|
-
SESSION_TIMEOUT_MS,
|
|
1291
|
-
SESSION_CLEANUP_INTERVAL_MS,
|
|
1292
|
-
RATE_LIMIT_TOKENS,
|
|
1293
|
-
SENSITIVE_ENV_PATTERNS,
|
|
1294
|
-
encodeWebSocketFrame,
|
|
1295
|
-
decodeWebSocketFrame,
|
|
1296
|
-
};
|