loki-mode 5.7.2 → 5.7.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/api/server.js ADDED
@@ -0,0 +1,637 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Loki Mode HTTP/SSE API Server
4
+ *
5
+ * Provides REST API and Server-Sent Events for loki-mode integration.
6
+ * Zero npm dependencies - uses only Node.js built-in modules.
7
+ *
8
+ * Usage:
9
+ * node api/server.js [--port 3000] [--host 127.0.0.1]
10
+ * loki serve [--port 3000]
11
+ *
12
+ * Endpoints:
13
+ * GET /health - Liveness check
14
+ * GET /status - Current session state + metrics
15
+ * GET /events - SSE stream of real-time events
16
+ * GET /logs - Recent log entries (?lines=100)
17
+ * POST /start - Start new session {"prd":"path","provider":"claude"}
18
+ * POST /stop - Graceful stop
19
+ * POST /pause - Pause execution
20
+ * POST /resume - Resume execution
21
+ * POST /input - Inject human input {"input":"directive text"}
22
+ *
23
+ * @version 1.0.0
24
+ */
25
+
26
+ const http = require('http');
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const { spawn } = require('child_process');
30
+ const { EventEmitter } = require('events');
31
+
32
+ //=============================================================================
33
+ // Configuration
34
+ //=============================================================================
35
+
36
+ const DEFAULT_PORT = 9898;
37
+ const DEFAULT_HOST = '127.0.0.1';
38
+ const PROJECT_DIR = process.env.LOKI_PROJECT_DIR || process.cwd();
39
+
40
+ // Security constants
41
+ const VALID_PROVIDERS = ['claude', 'codex', 'gemini'];
42
+ const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit
43
+ const MAX_LOG_LINES = 1000;
44
+ const LOKI_DIR = path.join(PROJECT_DIR, '.loki');
45
+ // Prompt injection disabled by default for enterprise security
46
+ const PROMPT_INJECTION_ENABLED = process.env.LOKI_PROMPT_INJECTION === 'true';
47
+
48
+ // Parse CLI args
49
+ const args = process.argv.slice(2);
50
+ let PORT = DEFAULT_PORT;
51
+ let HOST = DEFAULT_HOST;
52
+
53
+ for (let i = 0; i < args.length; i++) {
54
+ if (args[i] === '--port' && args[i + 1]) PORT = parseInt(args[i + 1], 10);
55
+ if (args[i] === '--host' && args[i + 1]) HOST = args[i + 1];
56
+ }
57
+
58
+ //=============================================================================
59
+ // Event Bus (SSE Broadcasting)
60
+ //=============================================================================
61
+
62
+ class EventBus extends EventEmitter {
63
+ constructor() {
64
+ super();
65
+ this.clients = new Set();
66
+ this.eventBuffer = [];
67
+ this.bufferSize = 100;
68
+ this.eventId = 0;
69
+ }
70
+
71
+ addClient(res) {
72
+ this.clients.add(res);
73
+ // Send buffered events to new client
74
+ for (const event of this.eventBuffer) {
75
+ this.sendToClient(res, event);
76
+ }
77
+ return () => this.clients.delete(res);
78
+ }
79
+
80
+ broadcast(type, data) {
81
+ const event = {
82
+ id: `evt_${++this.eventId}`,
83
+ type,
84
+ timestamp: new Date().toISOString(),
85
+ data
86
+ };
87
+
88
+ // Buffer for late joiners
89
+ this.eventBuffer.push(event);
90
+ if (this.eventBuffer.length > this.bufferSize) {
91
+ this.eventBuffer.shift();
92
+ }
93
+
94
+ // Broadcast to all clients
95
+ for (const client of this.clients) {
96
+ this.sendToClient(client, event);
97
+ }
98
+
99
+ this.emit('event', event);
100
+ }
101
+
102
+ sendToClient(res, event) {
103
+ try {
104
+ res.write(`id: ${event.id}\n`);
105
+ res.write(`event: ${event.type}\n`);
106
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
107
+ } catch (e) {
108
+ this.clients.delete(res);
109
+ }
110
+ }
111
+
112
+ startHeartbeat() {
113
+ this.heartbeatInterval = setInterval(() => {
114
+ this.broadcast('heartbeat', { time: Date.now() });
115
+ }, 30000);
116
+ }
117
+
118
+ stopHeartbeat() {
119
+ if (this.heartbeatInterval) {
120
+ clearInterval(this.heartbeatInterval);
121
+ this.heartbeatInterval = null;
122
+ }
123
+ }
124
+
125
+ cleanup() {
126
+ this.stopHeartbeat();
127
+ // Close all SSE clients
128
+ for (const client of this.clients) {
129
+ try {
130
+ client.end();
131
+ } catch (e) {
132
+ // Client may already be closed
133
+ }
134
+ }
135
+ this.clients.clear();
136
+ }
137
+ }
138
+
139
+ const eventBus = new EventBus();
140
+
141
+ //=============================================================================
142
+ // Process Manager (run.sh lifecycle)
143
+ //=============================================================================
144
+
145
+ class ProcessManager {
146
+ constructor() {
147
+ this.process = null;
148
+ this.status = 'idle'; // idle, starting, running, paused, stopping, completed, failed
149
+ this.startedAt = null;
150
+ this.prdPath = null;
151
+ this.provider = null;
152
+ this.fileWatcher = null;
153
+ this.lastDashboardState = null;
154
+ }
155
+
156
+ async start(options = {}) {
157
+ // Check for any active session (running, starting, or paused)
158
+ if (this.status === 'running' || this.status === 'starting' || this.status === 'paused') {
159
+ throw new Error('Session already running');
160
+ }
161
+
162
+ const { prd, provider = 'claude' } = options;
163
+
164
+ // Validate provider (security: prevent command injection)
165
+ if (!VALID_PROVIDERS.includes(provider)) {
166
+ throw new Error(`Invalid provider: ${provider}. Must be one of: ${VALID_PROVIDERS.join(', ')}`);
167
+ }
168
+
169
+ // Validate PRD path (security: prevent path traversal)
170
+ if (prd) {
171
+ const resolvedPrd = path.resolve(PROJECT_DIR, prd);
172
+ const resolvedProjectDir = path.resolve(PROJECT_DIR);
173
+ // Ensure resolved path is within project directory
174
+ if (!resolvedPrd.startsWith(resolvedProjectDir + path.sep) && resolvedPrd !== resolvedProjectDir) {
175
+ throw new Error('Invalid PRD path: path traversal not allowed');
176
+ }
177
+ }
178
+ this.prdPath = prd;
179
+ this.provider = provider;
180
+ this.status = 'starting';
181
+ this.startedAt = new Date().toISOString();
182
+
183
+ // Build command
184
+ const runScript = path.join(PROJECT_DIR, 'autonomy', 'run.sh');
185
+ const args = [];
186
+ if (provider && provider !== 'claude') {
187
+ args.push('--provider', provider);
188
+ }
189
+ if (prd) {
190
+ args.push(prd);
191
+ }
192
+
193
+ // Spawn run.sh
194
+ this.process = spawn('bash', [runScript, ...args], {
195
+ cwd: PROJECT_DIR,
196
+ env: {
197
+ ...process.env,
198
+ LOKI_API_MODE: '1',
199
+ LOKI_NO_DASHBOARD: '1', // Don't open browser
200
+ FORCE_COLOR: '0' // Disable ANSI colors for parsing
201
+ },
202
+ stdio: ['pipe', 'pipe', 'pipe']
203
+ });
204
+
205
+ this.status = 'running';
206
+ eventBus.broadcast('session:started', {
207
+ provider: this.provider,
208
+ prd: this.prdPath,
209
+ pid: this.process.pid
210
+ });
211
+
212
+ // Parse stdout for events
213
+ this.process.stdout.on('data', (chunk) => {
214
+ const lines = chunk.toString().split('\n');
215
+ for (const line of lines) {
216
+ if (line.trim()) {
217
+ this.parseLogLine(line);
218
+ }
219
+ }
220
+ });
221
+
222
+ this.process.stderr.on('data', (chunk) => {
223
+ const lines = chunk.toString().split('\n');
224
+ for (const line of lines) {
225
+ if (line.trim()) {
226
+ eventBus.broadcast('log:entry', { level: 'error', message: line });
227
+ }
228
+ }
229
+ });
230
+
231
+ // Handle exit
232
+ this.process.on('exit', (code, signal) => {
233
+ const success = code === 0;
234
+ this.status = success ? 'completed' : 'failed';
235
+ eventBus.broadcast(success ? 'session:completed' : 'session:failed', {
236
+ exitCode: code,
237
+ signal,
238
+ duration: Date.now() - new Date(this.startedAt).getTime()
239
+ });
240
+ this.process = null;
241
+ this.stopFileWatcher();
242
+ });
243
+
244
+ this.process.on('error', (err) => {
245
+ this.status = 'failed';
246
+ eventBus.broadcast('session:failed', { error: err.message });
247
+ this.process = null;
248
+ this.stopFileWatcher();
249
+ });
250
+
251
+ // Start watching .loki/ for state changes
252
+ this.startFileWatcher();
253
+
254
+ return { pid: this.process.pid, status: this.status };
255
+ }
256
+
257
+ parseLogLine(line) {
258
+ // Strip ANSI codes
259
+ const clean = line.replace(/\x1b\[[0-9;]*m/g, '');
260
+
261
+ // Detect event patterns
262
+ if (clean.includes('Phase:') || clean.includes('PHASE:')) {
263
+ const match = clean.match(/Phase:\s*(\w+)/i);
264
+ if (match) {
265
+ eventBus.broadcast('phase:changed', { phase: match[1] });
266
+ }
267
+ }
268
+
269
+ if (clean.includes('Task completed') || clean.includes('TASK COMPLETE')) {
270
+ eventBus.broadcast('task:completed', { message: clean });
271
+ }
272
+
273
+ if (clean.includes('Task started') || clean.includes('Starting task')) {
274
+ eventBus.broadcast('task:started', { message: clean });
275
+ }
276
+
277
+ if (clean.includes('Quality gate') || clean.includes('Gate:')) {
278
+ const passed = clean.toLowerCase().includes('pass');
279
+ eventBus.broadcast(passed ? 'gate:passed' : 'gate:failed', { message: clean });
280
+ }
281
+
282
+ // Always emit as log entry
283
+ const level = clean.includes('[ERROR]') ? 'error'
284
+ : clean.includes('[WARN]') ? 'warn'
285
+ : clean.includes('[DEBUG]') ? 'debug'
286
+ : 'info';
287
+ eventBus.broadcast('log:entry', { level, message: clean });
288
+ }
289
+
290
+ startFileWatcher() {
291
+ const dashboardPath = path.join(LOKI_DIR, 'dashboard-state.json');
292
+
293
+ // Poll for changes (more reliable than fs.watch across platforms)
294
+ this.fileWatcher = setInterval(async () => {
295
+ try {
296
+ const content = await fs.promises.readFile(dashboardPath, 'utf8');
297
+ const state = JSON.parse(content);
298
+
299
+ if (this.lastDashboardState) {
300
+ // Diff and emit changes
301
+ if (state.phase !== this.lastDashboardState.phase) {
302
+ eventBus.broadcast('phase:changed', {
303
+ phase: state.phase,
304
+ previous: this.lastDashboardState.phase
305
+ });
306
+ }
307
+ }
308
+
309
+ this.lastDashboardState = state;
310
+ } catch (e) {
311
+ // File doesn't exist yet or is being written
312
+ }
313
+ }, 1000);
314
+ }
315
+
316
+ stopFileWatcher() {
317
+ if (this.fileWatcher) {
318
+ clearInterval(this.fileWatcher);
319
+ this.fileWatcher = null;
320
+ }
321
+ }
322
+
323
+ async stop() {
324
+ if (!this.process) {
325
+ return { status: 'idle' };
326
+ }
327
+
328
+ this.status = 'stopping';
329
+
330
+ // Touch STOP file for graceful shutdown
331
+ const stopFile = path.join(LOKI_DIR, 'STOP');
332
+ await fs.promises.writeFile(stopFile, '');
333
+
334
+ // Wait for graceful exit (5s), then force kill
335
+ return new Promise((resolve) => {
336
+ const timeout = setTimeout(() => {
337
+ if (this.process) {
338
+ this.process.kill('SIGTERM');
339
+ }
340
+ }, 5000);
341
+
342
+ if (this.process) {
343
+ this.process.once('exit', () => {
344
+ clearTimeout(timeout);
345
+ this.status = 'idle';
346
+ resolve({ status: 'stopped' });
347
+ });
348
+ } else {
349
+ clearTimeout(timeout);
350
+ resolve({ status: 'idle' });
351
+ }
352
+ });
353
+ }
354
+
355
+ async pause() {
356
+ if (this.status !== 'running' && this.status !== 'starting') {
357
+ throw new Error('No running session to pause');
358
+ }
359
+
360
+ // Ensure .loki directory exists
361
+ await fs.promises.mkdir(LOKI_DIR, { recursive: true });
362
+
363
+ const pauseFile = path.join(LOKI_DIR, 'PAUSE');
364
+ await fs.promises.writeFile(pauseFile, '');
365
+ this.status = 'paused';
366
+ eventBus.broadcast('session:paused', {});
367
+ return { status: 'paused' };
368
+ }
369
+
370
+ async resume() {
371
+ if (this.status !== 'paused') {
372
+ throw new Error('Session is not paused');
373
+ }
374
+
375
+ const pauseFile = path.join(LOKI_DIR, 'PAUSE');
376
+ try {
377
+ await fs.promises.unlink(pauseFile);
378
+ } catch (e) {
379
+ // File might not exist
380
+ }
381
+ this.status = 'running';
382
+ eventBus.broadcast('session:resumed', {});
383
+ return { status: 'running' };
384
+ }
385
+
386
+ async injectInput(input) {
387
+ // Security: Prompt injection disabled by default for enterprise security
388
+ if (!PROMPT_INJECTION_ENABLED) {
389
+ throw new Error('Prompt injection is disabled for security. Set LOKI_PROMPT_INJECTION=true to enable (only in trusted environments).');
390
+ }
391
+
392
+ // Validate input
393
+ if (typeof input !== 'string' || input.length === 0) {
394
+ throw new Error('Input must be a non-empty string');
395
+ }
396
+ if (input.length > MAX_BODY_SIZE) {
397
+ throw new Error('Input too large');
398
+ }
399
+
400
+ // Ensure .loki directory exists
401
+ await fs.promises.mkdir(LOKI_DIR, { recursive: true });
402
+
403
+ const inputFile = path.join(LOKI_DIR, 'HUMAN_INPUT.md');
404
+ await fs.promises.writeFile(inputFile, input);
405
+ eventBus.broadcast('input:injected', { preview: input.slice(0, 100) });
406
+ return { status: 'injected' };
407
+ }
408
+
409
+ getStatus() {
410
+ return {
411
+ status: this.status,
412
+ pid: this.process?.pid || null,
413
+ provider: this.provider,
414
+ prd: this.prdPath,
415
+ startedAt: this.startedAt,
416
+ uptime: this.startedAt ? Date.now() - new Date(this.startedAt).getTime() : 0,
417
+ dashboard: this.lastDashboardState
418
+ };
419
+ }
420
+ }
421
+
422
+ const processManager = new ProcessManager();
423
+
424
+ //=============================================================================
425
+ // HTTP Request Handlers
426
+ //=============================================================================
427
+
428
+ async function handleRequest(req, res) {
429
+ const url = new URL(req.url, `http://${req.headers.host}`);
430
+ const method = req.method;
431
+ const pathname = url.pathname;
432
+
433
+ // CORS headers - restrict to localhost for security
434
+ // Use regex to match exact localhost origins with optional port
435
+ const origin = req.headers.origin || '';
436
+ const localhostPattern = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
437
+ const isAllowed = localhostPattern.test(origin);
438
+ res.setHeader('Access-Control-Allow-Origin', isAllowed ? origin : 'http://localhost');
439
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
440
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
441
+
442
+ if (method === 'OPTIONS') {
443
+ res.writeHead(204);
444
+ res.end();
445
+ return;
446
+ }
447
+
448
+ try {
449
+ // Route handling
450
+ if (method === 'GET' && pathname === '/health') {
451
+ return sendJson(res, 200, { status: 'ok', version: '1.0.0' });
452
+ }
453
+
454
+ if (method === 'GET' && pathname === '/status') {
455
+ return sendJson(res, 200, processManager.getStatus());
456
+ }
457
+
458
+ if (method === 'GET' && pathname === '/events') {
459
+ return handleSSE(req, res);
460
+ }
461
+
462
+ if (method === 'GET' && pathname === '/logs') {
463
+ const lines = Math.min(parseInt(url.searchParams.get('lines') || '50', 10), MAX_LOG_LINES);
464
+ return handleLogs(res, lines);
465
+ }
466
+
467
+ if (method === 'POST' && pathname === '/start') {
468
+ const body = await parseBody(req);
469
+ const result = await processManager.start(body);
470
+ return sendJson(res, 201, result);
471
+ }
472
+
473
+ if (method === 'POST' && pathname === '/stop') {
474
+ const result = await processManager.stop();
475
+ return sendJson(res, 200, result);
476
+ }
477
+
478
+ if (method === 'POST' && pathname === '/pause') {
479
+ const result = await processManager.pause();
480
+ return sendJson(res, 200, result);
481
+ }
482
+
483
+ if (method === 'POST' && pathname === '/resume') {
484
+ const result = await processManager.resume();
485
+ return sendJson(res, 200, result);
486
+ }
487
+
488
+ if (method === 'POST' && pathname === '/input') {
489
+ const body = await parseBody(req);
490
+ if (!body.input) {
491
+ return sendJson(res, 400, { error: 'Missing input field' });
492
+ }
493
+ const result = await processManager.injectInput(body.input);
494
+ return sendJson(res, 200, result);
495
+ }
496
+
497
+ // 404
498
+ sendJson(res, 404, { error: 'Not found', path: pathname });
499
+
500
+ } catch (err) {
501
+ console.error('Request error:', err);
502
+ sendJson(res, err.message.includes('already running') ? 409 : 500, {
503
+ error: err.message
504
+ });
505
+ }
506
+ }
507
+
508
+ function handleSSE(req, res) {
509
+ res.writeHead(200, {
510
+ 'Content-Type': 'text/event-stream',
511
+ 'Cache-Control': 'no-cache',
512
+ 'Connection': 'keep-alive'
513
+ });
514
+
515
+ // Send initial status
516
+ res.write(`event: connected\ndata: ${JSON.stringify({ status: processManager.status })}\n\n`);
517
+
518
+ // Register client
519
+ const removeClient = eventBus.addClient(res);
520
+
521
+ // Handle disconnect
522
+ req.on('close', removeClient);
523
+ }
524
+
525
+ async function handleLogs(res, lines) {
526
+ const logFiles = [
527
+ path.join(LOKI_DIR, 'logs', 'session.log'),
528
+ path.join(LOKI_DIR, 'logs', 'agent.log')
529
+ ];
530
+
531
+ const logs = [];
532
+ for (const logFile of logFiles) {
533
+ try {
534
+ const content = await fs.promises.readFile(logFile, 'utf8');
535
+ const fileLines = content.split('\n').filter(l => l.trim());
536
+ logs.push(...fileLines.slice(-lines));
537
+ } catch (e) {
538
+ // File doesn't exist
539
+ }
540
+ }
541
+
542
+ // Sort by timestamp if possible, return last N
543
+ sendJson(res, 200, {
544
+ lines: logs.slice(-lines),
545
+ count: logs.length
546
+ });
547
+ }
548
+
549
+ //=============================================================================
550
+ // Utilities
551
+ //=============================================================================
552
+
553
+ function sendJson(res, status, data) {
554
+ res.writeHead(status, { 'Content-Type': 'application/json' });
555
+ res.end(JSON.stringify(data, null, 2));
556
+ }
557
+
558
+ function parseBody(req) {
559
+ return new Promise((resolve, reject) => {
560
+ let body = '';
561
+ let size = 0;
562
+
563
+ req.on('data', chunk => {
564
+ size += chunk.length;
565
+ if (size > MAX_BODY_SIZE) {
566
+ req.destroy();
567
+ reject(new Error('Request body too large'));
568
+ return;
569
+ }
570
+ body += chunk;
571
+ });
572
+
573
+ req.on('end', () => {
574
+ try {
575
+ resolve(body ? JSON.parse(body) : {});
576
+ } catch (e) {
577
+ reject(new Error('Invalid JSON body'));
578
+ }
579
+ });
580
+
581
+ req.on('error', reject);
582
+ });
583
+ }
584
+
585
+ //=============================================================================
586
+ // Server Startup
587
+ //=============================================================================
588
+
589
+ const server = http.createServer(handleRequest);
590
+
591
+ // Graceful shutdown
592
+ function shutdown() {
593
+ console.log('\nShutting down...');
594
+ eventBus.cleanup();
595
+ processManager.stop().then(() => {
596
+ server.close(() => {
597
+ console.log('Server closed');
598
+ process.exit(0);
599
+ });
600
+ });
601
+ }
602
+
603
+ process.on('SIGTERM', shutdown);
604
+ process.on('SIGINT', shutdown);
605
+
606
+ // Ensure .loki directories exist
607
+ async function ensureDirectories() {
608
+ await fs.promises.mkdir(path.join(LOKI_DIR, 'logs'), { recursive: true });
609
+ await fs.promises.mkdir(path.join(LOKI_DIR, 'queue'), { recursive: true });
610
+ await fs.promises.mkdir(path.join(LOKI_DIR, 'state'), { recursive: true });
611
+ }
612
+
613
+ // Start server
614
+ ensureDirectories().then(() => {
615
+ server.listen(PORT, HOST, () => {
616
+ console.log(`Loki API server running at http://${HOST}:${PORT}`);
617
+ console.log(`Project directory: ${PROJECT_DIR}`);
618
+ console.log('');
619
+ console.log('Endpoints:');
620
+ console.log(' GET /health - Health check');
621
+ console.log(' GET /status - Session status');
622
+ console.log(' GET /events - SSE event stream');
623
+ console.log(' GET /logs - Recent logs');
624
+ console.log(' POST /start - Start session');
625
+ console.log(' POST /stop - Stop session');
626
+ console.log(' POST /pause - Pause session');
627
+ console.log(' POST /resume - Resume session');
628
+ console.log(' POST /input - Inject human input');
629
+ console.log('');
630
+ eventBus.startHeartbeat();
631
+ });
632
+ }).catch(err => {
633
+ console.error('Failed to initialize:', err);
634
+ process.exit(1);
635
+ });
636
+
637
+ module.exports = { server, processManager, eventBus };