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/VERSION +1 -1
- package/api/README.md +297 -0
- package/api/client.ts +377 -0
- package/api/middleware/auth.ts +129 -0
- package/api/middleware/cors.ts +145 -0
- package/api/middleware/error.ts +226 -0
- package/api/mod.ts +58 -0
- package/api/openapi.yaml +614 -0
- package/api/routes/events.ts +165 -0
- package/api/routes/health.ts +169 -0
- package/api/routes/sessions.ts +262 -0
- package/api/routes/tasks.ts +182 -0
- package/api/server.js +637 -0
- package/api/server.ts +328 -0
- package/api/server_test.ts +265 -0
- package/api/services/cli-bridge.ts +503 -0
- package/api/services/event-bus.ts +189 -0
- package/api/services/state-watcher.ts +517 -0
- package/api/test.js +494 -0
- package/api/types/api.ts +122 -0
- package/api/types/events.ts +132 -0
- package/autonomy/loki +28 -2
- package/package.json +3 -2
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 };
|