knoxis-helper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1137 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Knoxis Local Agent - Zero Dependencies Version
5
+ *
6
+ * Runs on the user's local machine to handle terminal operations
7
+ * when the backend is deployed in Azure.
8
+ *
9
+ * Usage:
10
+ * node knoxis-local-agent.js
11
+ * curl -L https://github.com/USER/REPO/releases/latest/download/knoxis-helper.js | node - pair
12
+ *
13
+ * The web frontend connects to this agent at http://localhost:3456
14
+ * to request terminal operations on the user's local machine.
15
+ *
16
+ * ZERO EXTERNAL DEPENDENCIES - uses only Node.js built-in modules
17
+ */
18
+
19
+ const http = require('http');
20
+ const https = require('https');
21
+ const { exec, spawn, spawnSync } = require('child_process');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const fs = require('fs');
25
+ const url = require('url');
26
+
27
+ const DEFAULT_PORT = parseInt(process.env.KNOXIS_AGENT_PORT || '3456', 10);
28
+ const CERT_DIR = process.env.KNOXIS_CERT_DIR || path.join(os.homedir(), '.knoxis', 'certs');
29
+ const CERT_FILE = process.env.KNOXIS_CERT_FILE || path.join(CERT_DIR, 'localhost.pem');
30
+ const KEY_FILE = process.env.KNOXIS_CERT_KEY || path.join(CERT_DIR, 'localhost-key.pem');
31
+
32
+ // Trusted origins for CORS (deployed frontends)
33
+ const TRUSTED_ORIGINS = [
34
+ 'https://qig.ai',
35
+ 'https://www.qig.ai',
36
+ 'https://app.qig.ai',
37
+ 'http://localhost:3000',
38
+ 'http://localhost:5173',
39
+ 'http://127.0.0.1:3000',
40
+ 'http://127.0.0.1:5173'
41
+ ];
42
+ const ALLOWED_ORIGINS = (process.env.KNOXIS_ALLOWED_ORIGINS || '')
43
+ .split(',')
44
+ .map(origin => origin.trim())
45
+ .filter(Boolean);
46
+ // Merge custom origins with trusted ones
47
+ const ALL_ALLOWED_ORIGINS = [...new Set([...TRUSTED_ORIGINS, ...ALLOWED_ORIGINS])];
48
+
49
+ const serverMeta = { secure: false, port: DEFAULT_PORT };
50
+
51
+ /**
52
+ * Generate self-signed certificate for HTTPS using OpenSSL (pre-installed on macOS/Linux)
53
+ */
54
+ function generateSelfSignedCert() {
55
+ console.log('šŸ” Generating self-signed certificate...');
56
+
57
+ if (!fs.existsSync(CERT_DIR)) {
58
+ fs.mkdirSync(CERT_DIR, { recursive: true });
59
+ }
60
+
61
+ const keyPath = path.join(CERT_DIR, 'localhost-key.pem');
62
+ const certPath = path.join(CERT_DIR, 'localhost.pem');
63
+
64
+ // Generate key + cert in one command
65
+ const result = spawnSync('openssl', [
66
+ 'req', '-x509', '-newkey', 'rsa:2048',
67
+ '-keyout', keyPath,
68
+ '-out', certPath,
69
+ '-days', '365',
70
+ '-nodes',
71
+ '-subj', '/CN=localhost',
72
+ '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1'
73
+ ], { stdio: 'pipe' });
74
+
75
+ if (result.status === 0) {
76
+ console.log('āœ… Certificate generated');
77
+ return true;
78
+ }
79
+
80
+ console.warn('āš ļø OpenSSL failed - running in HTTP mode');
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Get CORS headers for the given request origin
86
+ */
87
+ function getCorsHeaders(requestOrigin) {
88
+ const headers = {
89
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH',
90
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, Accept, Origin',
91
+ 'Access-Control-Max-Age': '86400'
92
+ };
93
+
94
+ // Check if origin is allowed
95
+ if (requestOrigin) {
96
+ const isAllowed = ALL_ALLOWED_ORIGINS.some(allowed => {
97
+ if (allowed === '*') return true;
98
+ return requestOrigin === allowed || requestOrigin.endsWith(allowed.replace('https://', '.'));
99
+ });
100
+
101
+ if (isAllowed) {
102
+ // Use specific origin (required when credentials are used)
103
+ headers['Access-Control-Allow-Origin'] = requestOrigin;
104
+ headers['Access-Control-Allow-Credentials'] = 'true';
105
+ } else {
106
+ // For unknown origins, allow without credentials
107
+ headers['Access-Control-Allow-Origin'] = '*';
108
+ }
109
+ } else {
110
+ headers['Access-Control-Allow-Origin'] = '*';
111
+ }
112
+
113
+ return headers;
114
+ }
115
+
116
+ function escapeForDoubleQuotedShellArg(value) {
117
+ return String(value || '')
118
+ .replace(/\\/g, '\\\\')
119
+ .replace(/"/g, '\\"')
120
+ .replace(/\$/g, '\\$')
121
+ .replace(/`/g, '\\`')
122
+ .replace(/!/g, '\\!');
123
+ }
124
+
125
+ const DEFAULT_HEADLESS_TIMEOUT_MS = parseInt(process.env.KNOXIS_HEADLESS_TIMEOUT_MS || '1200000', 10); // 20 min
126
+ const DEFAULT_HEADLESS_MAX_OUTPUT_CHARS = parseInt(process.env.KNOXIS_HEADLESS_MAX_OUTPUT_CHARS || '50000', 10);
127
+
128
+ function buildShellCommand(command) {
129
+ if (os.platform() === 'win32') {
130
+ return { cmd: 'cmd.exe', args: ['/c', command] };
131
+ }
132
+ return { cmd: 'bash', args: ['-lc', command] };
133
+ }
134
+
135
+ function commandExists(cmd) {
136
+ const detector = os.platform() === 'win32' ? 'where' : 'which';
137
+ const result = spawnSync(detector, [cmd], { stdio: 'ignore' });
138
+ return result.status === 0;
139
+ }
140
+
141
+ function safeBasename(value) {
142
+ return String(value || '').replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) || 'session';
143
+ }
144
+
145
+ function ensureDir(dirPath) {
146
+ if (!fs.existsSync(dirPath)) {
147
+ fs.mkdirSync(dirPath, { recursive: true });
148
+ }
149
+ }
150
+
151
+ function runHeadlessProcess({ workspace, command, prompt, sessionLabel }) {
152
+ return new Promise((resolve) => {
153
+ const startedAt = Date.now();
154
+ const workspaceDir = workspace || process.cwd();
155
+
156
+ const logDir = path.join(workspaceDir, '.knoxis', 'headless');
157
+ ensureDir(logDir);
158
+ const logFile = path.join(logDir, `${Date.now()}-${safeBasename(sessionLabel)}.log`);
159
+ const logStream = fs.createWriteStream(logFile, { encoding: 'utf8' });
160
+
161
+ const trimmedCommand = String(command || '').trim();
162
+ const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0;
163
+ const lowerCommand = trimmedCommand.toLowerCase();
164
+ const looksLikePairProgram = lowerCommand.includes('knoxis-pair-program');
165
+
166
+ let proc;
167
+ let stdout = '';
168
+ let stderr = '';
169
+ let truncated = false;
170
+ let timedOut = false;
171
+
172
+ const capture = (chunk, isStdErr) => {
173
+ const text = chunk.toString();
174
+ logStream.write(text);
175
+ if (isStdErr) {
176
+ stderr += text;
177
+ return;
178
+ }
179
+ if (stdout.length < DEFAULT_HEADLESS_MAX_OUTPUT_CHARS) {
180
+ stdout += text;
181
+ } else {
182
+ truncated = true;
183
+ }
184
+ };
185
+
186
+ const finish = (exitCode) => {
187
+ try { logStream.end(); } catch (e) {}
188
+ resolve({
189
+ success: exitCode === 0 && !timedOut,
190
+ exitCode,
191
+ timedOut,
192
+ truncated,
193
+ durationMs: Date.now() - startedAt,
194
+ workspace: workspaceDir,
195
+ logFile,
196
+ stdout: stdout.trim(),
197
+ stderr: stderr.trim(),
198
+ });
199
+ };
200
+
201
+ const timeout = setTimeout(() => {
202
+ timedOut = true;
203
+ try { proc.kill('SIGKILL'); } catch (e) {}
204
+ }, DEFAULT_HEADLESS_TIMEOUT_MS);
205
+
206
+ const spawnShell = (shellCommand) => {
207
+ const spec = buildShellCommand(shellCommand);
208
+ proc = spawn(spec.cmd, spec.args, { cwd: workspaceDir, env: process.env });
209
+ proc.stdout.on('data', chunk => capture(chunk, false));
210
+ proc.stderr.on('data', chunk => capture(chunk, true));
211
+ proc.on('close', code => {
212
+ clearTimeout(timeout);
213
+ finish(code == null ? 1 : code);
214
+ });
215
+ };
216
+
217
+ if (!looksLikePairProgram && hasPrompt && (!trimmedCommand || lowerCommand.startsWith('claude'))) {
218
+ if (!commandExists('claude')) {
219
+ clearTimeout(timeout);
220
+ finish(127);
221
+ return;
222
+ }
223
+
224
+ proc = spawn('claude', ['--dangerously-skip-permissions'], { cwd: workspaceDir, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
225
+ proc.stdout.on('data', chunk => capture(chunk, false));
226
+ proc.stderr.on('data', chunk => capture(chunk, true));
227
+ proc.on('close', code => {
228
+ clearTimeout(timeout);
229
+ finish(code == null ? 1 : code);
230
+ });
231
+
232
+ proc.stdin.write(prompt);
233
+ proc.stdin.end();
234
+ return;
235
+ }
236
+
237
+ if (trimmedCommand) {
238
+ spawnShell(trimmedCommand);
239
+ return;
240
+ }
241
+
242
+ // Fallback: no command, no prompt
243
+ clearTimeout(timeout);
244
+ finish(1);
245
+ });
246
+ }
247
+
248
+ // ===== WORKSPACE MANAGEMENT (self-contained) =====
249
+ const KNOXIS_DIR = path.join(os.homedir(), '.knoxis');
250
+ const WORKSPACES_FILE = path.join(KNOXIS_DIR, 'workspaces.json');
251
+ const RECENT_FILE = path.join(KNOXIS_DIR, 'recent.json');
252
+
253
+ function ensureKnoxisDir() {
254
+ if (!fs.existsSync(KNOXIS_DIR)) {
255
+ fs.mkdirSync(KNOXIS_DIR, { recursive: true });
256
+ }
257
+ }
258
+
259
+ function loadWorkspaces() {
260
+ ensureKnoxisDir();
261
+ try {
262
+ if (fs.existsSync(WORKSPACES_FILE)) {
263
+ return JSON.parse(fs.readFileSync(WORKSPACES_FILE, 'utf8'));
264
+ }
265
+ } catch (e) {
266
+ console.warn('āš ļø Failed to load workspaces:', e.message);
267
+ }
268
+ return {};
269
+ }
270
+
271
+ function saveWorkspaces(workspaces) {
272
+ ensureKnoxisDir();
273
+ fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(workspaces, null, 2));
274
+ }
275
+
276
+ function resolveWorkspacePath(nameOrPath) {
277
+ // Direct path
278
+ if (fs.existsSync(nameOrPath)) {
279
+ return path.resolve(nameOrPath);
280
+ }
281
+
282
+ // Try workspace registry
283
+ const workspaces = loadWorkspaces();
284
+ if (workspaces[nameOrPath]) {
285
+ return workspaces[nameOrPath];
286
+ }
287
+
288
+ // Fuzzy match
289
+ const lower = nameOrPath.toLowerCase();
290
+ for (const [name, wsPath] of Object.entries(workspaces)) {
291
+ if (name.toLowerCase().includes(lower)) {
292
+ return wsPath;
293
+ }
294
+ }
295
+
296
+ return null;
297
+ }
298
+
299
+ function discoverProjects() {
300
+ const discovered = {};
301
+ const searchDirs = [
302
+ path.join(os.homedir(), 'Projects'),
303
+ path.join(os.homedir(), 'IdeaProjects'),
304
+ path.join(os.homedir(), 'Developer'),
305
+ path.join(os.homedir(), 'Code'),
306
+ path.join(os.homedir(), 'dev'),
307
+ path.join(os.homedir(), 'src'),
308
+ path.join(os.homedir(), 'work'),
309
+ ];
310
+
311
+ for (const searchDir of searchDirs) {
312
+ if (fs.existsSync(searchDir)) {
313
+ try {
314
+ const entries = fs.readdirSync(searchDir, { withFileTypes: true });
315
+ for (const entry of entries) {
316
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
317
+ const projectPath = path.join(searchDir, entry.name);
318
+ // Check if it looks like a project
319
+ const hasGit = fs.existsSync(path.join(projectPath, '.git'));
320
+ const hasPackage = fs.existsSync(path.join(projectPath, 'package.json'));
321
+ const hasPom = fs.existsSync(path.join(projectPath, 'pom.xml'));
322
+ const hasSrc = fs.existsSync(path.join(projectPath, 'src'));
323
+
324
+ if (hasGit || hasPackage || hasPom || hasSrc) {
325
+ discovered[entry.name] = projectPath;
326
+ }
327
+ }
328
+ }
329
+ } catch (e) {
330
+ // Skip inaccessible directories
331
+ }
332
+ }
333
+ }
334
+
335
+ return discovered;
336
+ }
337
+
338
+ // ===== FILE FINDER (self-contained) =====
339
+ function findFilesRecursive(dir, patterns, results = [], maxDepth = 5, currentDepth = 0) {
340
+ if (currentDepth > maxDepth || results.length >= 20) return results;
341
+
342
+ const skipDirs = ['node_modules', '.git', 'target', 'build', 'dist', '__pycache__', 'venv', '.idea'];
343
+
344
+ try {
345
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
346
+
347
+ for (const entry of entries) {
348
+ if (results.length >= 20) break;
349
+
350
+ const fullPath = path.join(dir, entry.name);
351
+
352
+ if (entry.isDirectory()) {
353
+ if (!skipDirs.includes(entry.name) && !entry.name.startsWith('.')) {
354
+ findFilesRecursive(fullPath, patterns, results, maxDepth, currentDepth + 1);
355
+ }
356
+ } else if (entry.isFile()) {
357
+ const nameLower = entry.name.toLowerCase();
358
+ const matches = patterns.some(p => nameLower.includes(p.toLowerCase()));
359
+ if (matches) {
360
+ results.push(fullPath);
361
+ }
362
+ }
363
+ }
364
+ } catch (e) {
365
+ // Skip inaccessible directories
366
+ }
367
+
368
+ return results;
369
+ }
370
+
371
+ function extractSearchPatterns(query) {
372
+ const patterns = [];
373
+ const lower = query.toLowerCase();
374
+
375
+ // Map common terms to file patterns
376
+ const termMappings = {
377
+ 'controller': ['Controller', 'controller'],
378
+ 'service': ['Service', 'service'],
379
+ 'model': ['Model', 'Entity', 'model', 'entity'],
380
+ 'repo': ['Repository', 'Repo', 'repository'],
381
+ 'config': ['Config', 'config', '.yml', '.yaml', '.properties'],
382
+ 'test': ['Test', 'Spec', 'test', 'spec'],
383
+ 'component': ['Component', 'component', '.tsx', '.jsx'],
384
+ 'hook': ['use', 'Hook'],
385
+ 'api': ['Api', 'Endpoint', 'Route', 'api'],
386
+ 'voice': ['Voice', 'voice', 'Audio', 'audio'],
387
+ 'intent': ['Intent', 'intent'],
388
+ };
389
+
390
+ for (const [term, mappings] of Object.entries(termMappings)) {
391
+ if (lower.includes(term)) {
392
+ patterns.push(...mappings);
393
+ }
394
+ }
395
+
396
+ // Add individual words from query
397
+ const words = query.split(/\s+/).filter(w => w.length >= 3);
398
+ patterns.push(...words);
399
+
400
+ return [...new Set(patterns)];
401
+ }
402
+
403
+ // Helper to parse JSON body
404
+ function parseBody(req) {
405
+ return new Promise((resolve, reject) => {
406
+ let body = '';
407
+ req.on('data', chunk => { body += chunk.toString(); });
408
+ req.on('end', () => {
409
+ try {
410
+ resolve(body ? JSON.parse(body) : {});
411
+ } catch (err) {
412
+ reject(err);
413
+ }
414
+ });
415
+ req.on('error', reject);
416
+ });
417
+ }
418
+
419
+ // Helper to send JSON response
420
+ function sendJSON(res, statusCode, data, requestOrigin) {
421
+ const corsHeaders = getCorsHeaders(requestOrigin);
422
+ res.writeHead(statusCode, {
423
+ 'Content-Type': 'application/json',
424
+ ...corsHeaders
425
+ });
426
+ res.end(JSON.stringify(data));
427
+ }
428
+
429
+ // Request handler
430
+ async function handleRequest(req, res) {
431
+ const parsedUrl = url.parse(req.url, true);
432
+ const pathname = parsedUrl.pathname;
433
+ const method = req.method;
434
+ const requestOrigin = req.headers.origin || req.headers.referer?.replace(/\/$/, '') || '';
435
+
436
+ // Handle CORS preflight
437
+ if (method === 'OPTIONS') {
438
+ const corsHeaders = getCorsHeaders(requestOrigin);
439
+ res.writeHead(204, corsHeaders);
440
+ return res.end();
441
+ }
442
+
443
+ // Health check
444
+ if (pathname === '/health' && method === 'GET') {
445
+ return sendJSON(res, 200, {
446
+ status: 'healthy',
447
+ platform: os.platform(),
448
+ agent: 'knoxis-local-agent',
449
+ version: '2.1.0-https',
450
+ secure: serverMeta.secure,
451
+ port: serverMeta.port,
452
+ dependencies: 'none',
453
+ allowedOrigins: ALL_ALLOWED_ORIGINS
454
+ }, requestOrigin);
455
+ }
456
+
457
+ // Execute terminal command
458
+ if (pathname === '/terminal/execute' && method === 'POST') {
459
+ try {
460
+ const body = await parseBody(req);
461
+ const { workspaceDirectory, workingDirectory, command, prompt, headless, sessionId } = body;
462
+ const workspace = workspaceDirectory || workingDirectory;
463
+
464
+ console.log('šŸ“‚ Workspace:', workspace);
465
+ console.log('šŸ“ Command:', command);
466
+ if (prompt) console.log('šŸ’¬ Prompt length:', prompt.length);
467
+ if (headless) console.log('🫄 Headless:', true);
468
+
469
+ const platform = os.platform();
470
+
471
+ if (headless) {
472
+ const result = await runHeadlessProcess({
473
+ workspace,
474
+ command,
475
+ prompt,
476
+ sessionLabel: sessionId || 'pair'
477
+ });
478
+ return sendJSON(res, result.success ? 200 : 500, result, requestOrigin);
479
+ }
480
+
481
+ if (platform === 'darwin') {
482
+ await openMacTerminal(workspace, command);
483
+ return sendJSON(res, 200, { success: true, message: 'Terminal opened on macOS', platform: 'darwin' }, requestOrigin);
484
+ }
485
+ if (platform === 'win32') {
486
+ await openWindowsTerminal(workspace, command);
487
+ return sendJSON(res, 200, { success: true, message: 'Terminal opened on Windows', platform: 'win32' }, requestOrigin);
488
+ }
489
+
490
+ await openLinuxTerminal(workspace, command);
491
+ return sendJSON(res, 200, { success: true, message: 'Terminal opened on Linux', platform: 'linux' }, requestOrigin);
492
+
493
+ } catch (error) {
494
+ console.error('āŒ Failed to open terminal:', error);
495
+ return sendJSON(res, 500, { success: false, error: error.message }, requestOrigin);
496
+ }
497
+ }
498
+
499
+ // Directory check
500
+ if (pathname === '/directory/check' && method === 'GET') {
501
+ const dirPath = parsedUrl.query.path;
502
+
503
+ if (!dirPath) {
504
+ return sendJSON(res, 400, { success: false, error: 'Path parameter required' }, requestOrigin);
505
+ }
506
+
507
+ try {
508
+ const exists = fs.existsSync(dirPath);
509
+ const stats = exists ? fs.statSync(dirPath) : null;
510
+
511
+ return sendJSON(res, 200, {
512
+ success: true,
513
+ exists,
514
+ isDirectory: stats ? stats.isDirectory() : false,
515
+ path: dirPath
516
+ }, requestOrigin);
517
+ } catch (error) {
518
+ return sendJSON(res, 200, { success: false, exists: false, error: error.message }, requestOrigin);
519
+ }
520
+ }
521
+
522
+ // ===== WORKSPACE MANAGEMENT ENDPOINTS =====
523
+
524
+ // List all workspaces
525
+ if (pathname === '/workspace/list' && method === 'GET') {
526
+ const workspaces = loadWorkspaces();
527
+ const list = Object.entries(workspaces).map(([name, wsPath]) => ({
528
+ name,
529
+ path: wsPath,
530
+ exists: fs.existsSync(wsPath)
531
+ }));
532
+ return sendJSON(res, 200, { success: true, workspaces: list }, requestOrigin);
533
+ }
534
+
535
+ // Get workspace by name (with fuzzy matching)
536
+ if (pathname === '/workspace/get' && method === 'GET') {
537
+ const name = parsedUrl.query.name;
538
+ if (!name) {
539
+ return sendJSON(res, 400, { success: false, error: 'Name parameter required' }, requestOrigin);
540
+ }
541
+
542
+ const wsPath = resolveWorkspacePath(name);
543
+ if (wsPath) {
544
+ return sendJSON(res, 200, { success: true, name, path: wsPath }, requestOrigin);
545
+ }
546
+ return sendJSON(res, 404, { success: false, error: `Workspace not found: ${name}` }, requestOrigin);
547
+ }
548
+
549
+ // Save workspace
550
+ if (pathname === '/workspace/save' && method === 'POST') {
551
+ try {
552
+ const body = await parseBody(req);
553
+ const { name, path: wsPath } = body;
554
+
555
+ if (!name) {
556
+ return sendJSON(res, 400, { success: false, error: 'Name required' }, requestOrigin);
557
+ }
558
+
559
+ const pathToSave = wsPath || process.cwd();
560
+ const resolved = path.resolve(pathToSave);
561
+
562
+ if (!fs.existsSync(resolved)) {
563
+ return sendJSON(res, 400, { success: false, error: `Path does not exist: ${resolved}` }, requestOrigin);
564
+ }
565
+
566
+ const workspaces = loadWorkspaces();
567
+ workspaces[name] = resolved;
568
+ saveWorkspaces(workspaces);
569
+
570
+ return sendJSON(res, 200, { success: true, name, path: resolved }, requestOrigin);
571
+ } catch (error) {
572
+ return sendJSON(res, 500, { success: false, error: error.message }, requestOrigin);
573
+ }
574
+ }
575
+
576
+ // Remove workspace
577
+ if (pathname === '/workspace/remove' && method === 'DELETE') {
578
+ const name = parsedUrl.query.name;
579
+ if (!name) {
580
+ return sendJSON(res, 400, { success: false, error: 'Name parameter required' }, requestOrigin);
581
+ }
582
+
583
+ const workspaces = loadWorkspaces();
584
+ if (workspaces[name]) {
585
+ delete workspaces[name];
586
+ saveWorkspaces(workspaces);
587
+ return sendJSON(res, 200, { success: true, message: `Removed workspace: ${name}` }, requestOrigin);
588
+ }
589
+ return sendJSON(res, 404, { success: false, error: `Workspace not found: ${name}` }, requestOrigin);
590
+ }
591
+
592
+ // Discover projects automatically
593
+ if (pathname === '/workspace/discover' && method === 'POST') {
594
+ const discovered = discoverProjects();
595
+ const workspaces = loadWorkspaces();
596
+ let added = 0;
597
+
598
+ for (const [name, wsPath] of Object.entries(discovered)) {
599
+ if (!workspaces[name]) {
600
+ workspaces[name] = wsPath;
601
+ added++;
602
+ }
603
+ }
604
+
605
+ saveWorkspaces(workspaces);
606
+
607
+ return sendJSON(res, 200, {
608
+ success: true,
609
+ discovered: Object.keys(discovered).length,
610
+ added,
611
+ workspaces: Object.entries(workspaces).map(([name, wsPath]) => ({ name, path: wsPath }))
612
+ }, requestOrigin);
613
+ }
614
+
615
+ // ===== FILE FINDER ENDPOINTS =====
616
+
617
+ // Find files in workspace
618
+ if (pathname === '/files/find' && method === 'GET') {
619
+ const query = parsedUrl.query.query || parsedUrl.query.q;
620
+ const workspace = parsedUrl.query.workspace || parsedUrl.query.w;
621
+ const maxResults = parseInt(parsedUrl.query.max || '10');
622
+
623
+ if (!query) {
624
+ return sendJSON(res, 400, { success: false, error: 'Query parameter required' }, requestOrigin);
625
+ }
626
+
627
+ let searchDir = process.cwd();
628
+ if (workspace) {
629
+ const wsPath = resolveWorkspacePath(workspace);
630
+ if (wsPath) {
631
+ searchDir = wsPath;
632
+ } else {
633
+ return sendJSON(res, 404, { success: false, error: `Workspace not found: ${workspace}` }, requestOrigin);
634
+ }
635
+ }
636
+
637
+ const patterns = extractSearchPatterns(query);
638
+ const results = findFilesRecursive(searchDir, patterns, [], 6, 0);
639
+
640
+ const files = results.slice(0, maxResults).map(fullPath => ({
641
+ path: fullPath.replace(searchDir + '/', ''),
642
+ fullPath,
643
+ name: path.basename(fullPath)
644
+ }));
645
+
646
+ return sendJSON(res, 200, {
647
+ success: true,
648
+ query,
649
+ workspace: workspace || null,
650
+ searchDir,
651
+ patterns,
652
+ files
653
+ }, requestOrigin);
654
+ }
655
+
656
+ // ===== PAIR PROGRAMMING ENDPOINTS =====
657
+
658
+ // Start pair programming session (opens terminal)
659
+ if (pathname === '/pair/start' && method === 'POST') {
660
+ try {
661
+ const body = await parseBody(req);
662
+ const { workspace, task, file, provider, headless, sessionId } = body;
663
+
664
+ if (!task) {
665
+ return sendJSON(res, 400, { success: false, error: 'Task description required' }, requestOrigin);
666
+ }
667
+
668
+ let workspaceDir = process.cwd();
669
+ if (workspace) {
670
+ const wsPath = resolveWorkspacePath(workspace);
671
+ if (wsPath) {
672
+ workspaceDir = wsPath;
673
+ } else {
674
+ return sendJSON(res, 404, { success: false, error: `Workspace not found: ${workspace}` }, requestOrigin);
675
+ }
676
+ }
677
+
678
+ // Build the prompt for Claude
679
+ let prompt = task;
680
+ if (file) {
681
+ prompt = `Working on file: ${file}\n\nTask: ${task}`;
682
+ }
683
+
684
+ // Build command - use claude with auto-approve
685
+ const command = `claude --dangerously-skip-permissions "${escapeForDoubleQuotedShellArg(prompt)}"`;
686
+
687
+ if (headless) {
688
+ const result = await runHeadlessProcess({
689
+ workspace: workspaceDir,
690
+ command: provider && String(provider).toLowerCase() === 'codex' ? 'codex' : 'claude',
691
+ prompt,
692
+ sessionLabel: sessionId || 'pair'
693
+ });
694
+ return sendJSON(res, result.success ? 200 : 500, result, requestOrigin);
695
+ }
696
+
697
+ const platform = os.platform();
698
+ if (platform === 'darwin') {
699
+ await openMacTerminal(workspaceDir, command);
700
+ } else if (platform === 'win32') {
701
+ await openWindowsTerminal(workspaceDir, command);
702
+ } else {
703
+ await openLinuxTerminal(workspaceDir, command);
704
+ }
705
+
706
+ return sendJSON(res, 200, {
707
+ success: true,
708
+ message: 'Pair programming session started',
709
+ workspace: workspaceDir,
710
+ task,
711
+ file: file || null
712
+ }, requestOrigin);
713
+ } catch (error) {
714
+ return sendJSON(res, 500, { success: false, error: error.message }, requestOrigin);
715
+ }
716
+ }
717
+
718
+ // 404
719
+ sendJSON(res, 404, { error: 'Not found' }, requestOrigin);
720
+ }
721
+
722
+ /**
723
+ * Open terminal on macOS
724
+ */
725
+ function openMacTerminal(workspaceDir, command) {
726
+ return new Promise((resolve, reject) => {
727
+ if (!fs.existsSync(workspaceDir)) {
728
+ fs.mkdirSync(workspaceDir, { recursive: true });
729
+ }
730
+
731
+ const escapedDir = workspaceDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
732
+ let finalCommand = command;
733
+
734
+ if (command.includes('claude') && !command.includes('--dangerously-skip-permissions')) {
735
+ finalCommand = command.replace(/^claude\s+/, 'claude --dangerously-skip-permissions ');
736
+ }
737
+
738
+ const escapedCommand = finalCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
739
+
740
+ const appleScript = `
741
+ tell application "Terminal"
742
+ activate
743
+ set newTab to do script "cd \\"${escapedDir}\\""
744
+ delay 0.5
745
+ do script "${escapedCommand}" in newTab
746
+ end tell
747
+ `.trim();
748
+
749
+ const tempScript = `/tmp/knoxis-terminal-${Date.now()}.scpt`;
750
+ fs.writeFileSync(tempScript, appleScript);
751
+
752
+ exec(`osascript "${tempScript}"`, (error) => {
753
+ try { fs.unlinkSync(tempScript); } catch (e) {}
754
+
755
+ if (error) {
756
+ console.error('āŒ AppleScript error:', error);
757
+ reject(error);
758
+ } else {
759
+ console.log('āœ… Terminal opened on macOS');
760
+ resolve();
761
+ }
762
+ });
763
+ });
764
+ }
765
+
766
+ /**
767
+ * Open terminal on Windows
768
+ */
769
+ function openWindowsTerminal(workspaceDir, command) {
770
+ return new Promise((resolve, reject) => {
771
+ if (!fs.existsSync(workspaceDir)) {
772
+ fs.mkdirSync(workspaceDir, { recursive: true });
773
+ }
774
+
775
+ const fullCommand = `start cmd /K "cd /d "${workspaceDir}" && ${command}"`;
776
+
777
+ exec(fullCommand, (error) => {
778
+ if (error) {
779
+ console.error('āŒ Windows terminal error:', error);
780
+ reject(error);
781
+ } else {
782
+ console.log('āœ… Terminal opened on Windows');
783
+ resolve();
784
+ }
785
+ });
786
+ });
787
+ }
788
+
789
+ /**
790
+ * Open terminal on Linux
791
+ */
792
+ function openLinuxTerminal(workspaceDir, command) {
793
+ return new Promise((resolve, reject) => {
794
+ if (!fs.existsSync(workspaceDir)) {
795
+ fs.mkdirSync(workspaceDir, { recursive: true });
796
+ }
797
+
798
+ const fullCommand = `gnome-terminal --working-directory="${workspaceDir}" -- bash -c "${command}; exec bash"`;
799
+
800
+ exec(fullCommand, (error) => {
801
+ if (error) {
802
+ const xtermCommand = `xterm -e "cd '${workspaceDir}' && ${command}; bash"`;
803
+ exec(xtermCommand, (error2) => {
804
+ if (error2) {
805
+ console.error('āŒ Linux terminal error:', error2);
806
+ reject(error2);
807
+ } else {
808
+ console.log('āœ… Terminal opened on Linux (xterm)');
809
+ resolve();
810
+ }
811
+ });
812
+ } else {
813
+ console.log('āœ… Terminal opened on Linux (gnome-terminal)');
814
+ resolve();
815
+ }
816
+ });
817
+ });
818
+ }
819
+
820
+ function createServer() {
821
+ // Try to load existing certs
822
+ try {
823
+ if (fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) {
824
+ const key = fs.readFileSync(KEY_FILE);
825
+ const cert = fs.readFileSync(CERT_FILE);
826
+ serverMeta.secure = true;
827
+ return https.createServer({ key, cert }, handleRequest);
828
+ }
829
+ } catch (err) {
830
+ console.warn('āš ļø Failed to load TLS certificates:', err.message);
831
+ }
832
+
833
+ // No certs exist - try to generate them
834
+ if (!fs.existsSync(CERT_FILE) || !fs.existsSync(KEY_FILE)) {
835
+ const generated = generateSelfSignedCert();
836
+ if (generated && fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) {
837
+ try {
838
+ const key = fs.readFileSync(KEY_FILE);
839
+ const cert = fs.readFileSync(CERT_FILE);
840
+ serverMeta.secure = true;
841
+ return https.createServer({ key, cert }, handleRequest);
842
+ } catch (err) {
843
+ console.warn('āš ļø Failed to load generated certificates:', err.message);
844
+ }
845
+ }
846
+ }
847
+
848
+ // Fallback to HTTP (will cause mixed content issues from HTTPS frontends)
849
+ serverMeta.secure = false;
850
+ console.warn('');
851
+ console.warn('āš ļø RUNNING IN HTTP MODE - This will cause 405/CORS errors from HTTPS frontends!');
852
+ console.warn('');
853
+ return http.createServer(handleRequest);
854
+ }
855
+
856
+ function ensureLaunchAgentIfNeeded() {
857
+ if (process.platform !== 'darwin') return;
858
+ if (process.env.KNOXIS_SKIP_LAUNCH_AGENT === '1') return;
859
+
860
+ try {
861
+ const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
862
+ if (!fs.existsSync(agentsDir)) {
863
+ fs.mkdirSync(agentsDir, { recursive: true });
864
+ }
865
+
866
+ const plistPath = path.join(agentsDir, 'com.knoxis.helper.plist');
867
+ const programArgs = [process.execPath, __filename];
868
+
869
+ const logDir = path.join(os.homedir(), 'Library', 'Logs');
870
+ if (!fs.existsSync(logDir)) {
871
+ fs.mkdirSync(logDir, { recursive: true });
872
+ }
873
+ const stdoutPath = path.join(logDir, 'KnoxisHelper.log');
874
+
875
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
876
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
877
+ <plist version="1.0">
878
+ <dict>
879
+ <key>Label</key>
880
+ <string>com.knoxis.helper</string>
881
+ <key>ProgramArguments</key>
882
+ <array>
883
+ ${programArgs.map(arg => `<string>${arg}</string>`).join('\n ')}
884
+ </array>
885
+ <key>RunAtLoad</key>
886
+ <true/>
887
+ <key>KeepAlive</key>
888
+ <true/>
889
+ <key>StandardOutPath</key>
890
+ <string>${stdoutPath}</string>
891
+ <key>StandardErrorPath</key>
892
+ <string>${stdoutPath}</string>
893
+ </dict>
894
+ </plist>`;
895
+
896
+ let needsWrite = true;
897
+ if (fs.existsSync(plistPath)) {
898
+ const current = fs.readFileSync(plistPath, 'utf8');
899
+ if (current === plist) needsWrite = false;
900
+ }
901
+
902
+ if (needsWrite) {
903
+ fs.writeFileSync(plistPath, plist, 'utf8');
904
+ spawnSync('launchctl', ['unload', plistPath]);
905
+ const load = spawnSync('launchctl', ['load', '-w', plistPath]);
906
+ if (load.status === 0) {
907
+ console.log('šŸ› ļø Installed launch agent to auto-start Knoxis helper');
908
+ } else {
909
+ console.warn('āš ļø Unable to automatically load launch agent. Run:');
910
+ console.warn(` launchctl load -w ${plistPath}`);
911
+ }
912
+ }
913
+
914
+ } catch (err) {
915
+ console.warn('āš ļø Unable to configure auto-start for Knoxis helper:', err.message);
916
+ }
917
+ }
918
+
919
+ // ===== WEBSOCKET RELAY CLIENT =====
920
+ // Connects to the deployed backend's WebSocket to receive pair programming commands
921
+ // without needing the frontend to relay. Backend pushes commands directly.
922
+
923
+ const BACKEND_WS_URL = process.env.KNOXIS_BACKEND_WS_URL || null;
924
+ const RELAY_USER_ID = process.env.KNOXIS_USER_ID || null;
925
+ const RELAY_RECONNECT_INTERVAL_MS = parseInt(process.env.KNOXIS_RECONNECT_MS || '10000', 10);
926
+
927
+ let relaySocket = null;
928
+ let relayReconnectTimer = null;
929
+
930
+ function connectRelayWebSocket() {
931
+ if (!BACKEND_WS_URL || !RELAY_USER_ID) {
932
+ return; // Not configured - skip relay
933
+ }
934
+
935
+ const wsUrl = `${BACKEND_WS_URL}/ws/knoxis-terminal?userId=${encodeURIComponent(RELAY_USER_ID)}`;
936
+ console.log(`šŸ”Œ Connecting relay to: ${wsUrl}`);
937
+
938
+ // Use Node.js built-in WebSocket (Node 22+) or ws module
939
+ let WebSocketImpl;
940
+ try {
941
+ WebSocketImpl = globalThis.WebSocket || require('ws');
942
+ } catch (e) {
943
+ // For older Node versions without built-in WebSocket, try dynamic import or skip
944
+ try {
945
+ WebSocketImpl = require('ws');
946
+ } catch (e2) {
947
+ console.warn('āš ļø WebSocket relay requires Node 22+ or the "ws" package. Relay disabled.');
948
+ console.warn(' Install with: npm install ws');
949
+ return;
950
+ }
951
+ }
952
+
953
+ try {
954
+ relaySocket = new WebSocketImpl(wsUrl);
955
+ } catch (e) {
956
+ console.warn('āš ļø Failed to create WebSocket connection:', e.message);
957
+ scheduleRelayReconnect();
958
+ return;
959
+ }
960
+
961
+ relaySocket.onopen = () => {
962
+ console.log('āœ… Relay WebSocket connected to backend');
963
+ if (relayReconnectTimer) {
964
+ clearTimeout(relayReconnectTimer);
965
+ relayReconnectTimer = null;
966
+ }
967
+ };
968
+
969
+ relaySocket.onmessage = async (event) => {
970
+ try {
971
+ const data = typeof event.data === 'string' ? event.data : event.data.toString();
972
+ const msg = JSON.parse(data);
973
+
974
+ if (msg.type === 'execute_command') {
975
+ console.log(`šŸ“„ Relay command received: ${msg.requestId}`);
976
+ console.log(` šŸ“‚ Dir: ${msg.workingDir}`);
977
+ console.log(` šŸ“ Cmd: ${(msg.command || '').substring(0, 100)}...`);
978
+
979
+ const workspace = msg.workingDir || process.cwd();
980
+ const command = msg.command || '';
981
+
982
+ let result;
983
+ try {
984
+ if (msg.headless) {
985
+ result = await runHeadlessProcess({
986
+ workspace,
987
+ command,
988
+ prompt: msg.prompt,
989
+ sessionLabel: msg.requestId || 'relay'
990
+ });
991
+ } else {
992
+ const platform = os.platform();
993
+ if (platform === 'darwin') {
994
+ await openMacTerminal(workspace, command);
995
+ } else if (platform === 'win32') {
996
+ await openWindowsTerminal(workspace, command);
997
+ } else {
998
+ await openLinuxTerminal(workspace, command);
999
+ }
1000
+ result = { success: true, message: 'Terminal opened via relay' };
1001
+ }
1002
+ } catch (err) {
1003
+ result = { success: false, error: err.message };
1004
+ }
1005
+
1006
+ // Send result back to backend
1007
+ const response = JSON.stringify({
1008
+ type: 'command_result',
1009
+ requestId: msg.requestId,
1010
+ ...result,
1011
+ timestamp: Date.now()
1012
+ });
1013
+
1014
+ if (relaySocket && relaySocket.readyState === 1) { // OPEN
1015
+ relaySocket.send(response);
1016
+ console.log(`šŸ“¤ Relay result sent for: ${msg.requestId} (success: ${result.success})`);
1017
+ }
1018
+ } else if (msg.type === 'connected') {
1019
+ console.log(`šŸ¤ Backend acknowledged: ${msg.message}`);
1020
+ }
1021
+ } catch (e) {
1022
+ console.error('āŒ Relay message handling error:', e.message);
1023
+ }
1024
+ };
1025
+
1026
+ relaySocket.onclose = (event) => {
1027
+ console.log(`šŸ”Œ Relay WebSocket closed (code: ${event.code || 'unknown'})`);
1028
+ relaySocket = null;
1029
+ scheduleRelayReconnect();
1030
+ };
1031
+
1032
+ relaySocket.onerror = (err) => {
1033
+ console.error('āŒ Relay WebSocket error:', err.message || 'connection failed');
1034
+ // onclose will fire after this, triggering reconnect
1035
+ };
1036
+ }
1037
+
1038
+ function scheduleRelayReconnect() {
1039
+ if (relayReconnectTimer) return;
1040
+ if (!BACKEND_WS_URL || !RELAY_USER_ID) return;
1041
+
1042
+ console.log(`ā³ Relay reconnecting in ${RELAY_RECONNECT_INTERVAL_MS / 1000}s...`);
1043
+ relayReconnectTimer = setTimeout(() => {
1044
+ relayReconnectTimer = null;
1045
+ connectRelayWebSocket();
1046
+ }, RELAY_RECONNECT_INTERVAL_MS);
1047
+ }
1048
+
1049
+ // Send heartbeat to keep relay alive
1050
+ function startRelayHeartbeat() {
1051
+ if (!BACKEND_WS_URL || !RELAY_USER_ID) return;
1052
+
1053
+ setInterval(() => {
1054
+ if (relaySocket && relaySocket.readyState === 1) {
1055
+ try {
1056
+ relaySocket.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }));
1057
+ } catch (e) {
1058
+ // Will reconnect on close
1059
+ }
1060
+ }
1061
+ }, 30000); // Every 30 seconds
1062
+ }
1063
+
1064
+ // Graceful shutdown
1065
+ function shutdown() {
1066
+ console.log('\nšŸ‘‹ Shutting down Knoxis Local Agent...');
1067
+ if (relaySocket) {
1068
+ try { relaySocket.close(); } catch (e) {}
1069
+ }
1070
+ process.exit(0);
1071
+ }
1072
+
1073
+ process.on('SIGINT', shutdown);
1074
+ process.on('SIGTERM', shutdown);
1075
+
1076
+ // Start the server
1077
+ ensureLaunchAgentIfNeeded();
1078
+ const server = createServer();
1079
+
1080
+ // Start WebSocket relay if configured
1081
+ connectRelayWebSocket();
1082
+ startRelayHeartbeat();
1083
+
1084
+ server.listen(serverMeta.port, () => {
1085
+ const scheme = serverMeta.secure ? 'https' : 'http';
1086
+ console.log('');
1087
+ console.log('╔══════════════════════════════════════════════════════════════╗');
1088
+ console.log('ā•‘ šŸš€ KNOXIS LOCAL AGENT v2.1.0 (HTTPS + CORS Fix) ā•‘');
1089
+ console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•');
1090
+ console.log('');
1091
+ console.log(`šŸ”’ Mode: ${serverMeta.secure ? 'HTTPS (Secure)' : 'HTTP (Insecure - see warning below)'}`);
1092
+ console.log(`🌐 Listening: ${scheme}://localhost:${serverMeta.port}`);
1093
+ console.log(`šŸ“¦ Dependencies: NONE (pure Node.js)`);
1094
+ console.log('');
1095
+ console.log('šŸ” Allowed Origins (CORS):');
1096
+ ALL_ALLOWED_ORIGINS.forEach(origin => console.log(` āœ“ ${origin}`));
1097
+ console.log('');
1098
+ console.log('šŸ“ Workspace Management:');
1099
+ console.log(' GET /workspace/list - List saved workspaces');
1100
+ console.log(' GET /workspace/get?name=X - Get workspace path (fuzzy match)');
1101
+ console.log(' POST /workspace/save - Save workspace {name, path}');
1102
+ console.log(' POST /workspace/discover - Auto-discover projects');
1103
+ console.log('');
1104
+ console.log('šŸ” File Finder:');
1105
+ console.log(' GET /files/find?q=X&w=Y - Find files by query in workspace');
1106
+ console.log('');
1107
+ console.log('šŸ¤ Pair Programming:');
1108
+ console.log(' POST /pair/start - Start session {workspace, task, file}');
1109
+ console.log('');
1110
+ if (BACKEND_WS_URL && RELAY_USER_ID) {
1111
+ console.log('šŸ”Œ WebSocket Relay:');
1112
+ console.log(` Backend: ${BACKEND_WS_URL}`);
1113
+ console.log(` User ID: ${RELAY_USER_ID}`);
1114
+ console.log(' Status: Connecting...');
1115
+ console.log('');
1116
+ } else {
1117
+ console.log('šŸ“” WebSocket Relay: Not configured');
1118
+ console.log(' Set KNOXIS_BACKEND_WS_URL and KNOXIS_USER_ID to enable');
1119
+ console.log(` Example: KNOXIS_BACKEND_WS_URL=wss://your-backend.azurecontainerapps.io KNOXIS_USER_ID=your-uuid node ${path.basename(__filename)}`);
1120
+ console.log('');
1121
+ }
1122
+
1123
+ if (!serverMeta.secure) {
1124
+ console.warn('╔══════════════════════════════════════════════════════════════╗');
1125
+ console.warn('ā•‘ āš ļø WARNING: Running in HTTP mode (no TLS certificates) ā•‘');
1126
+ console.warn('ā•‘ Browsers will block requests from HTTPS sites (like qig.ai) ā•‘');
1127
+ console.warn('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•');
1128
+ console.warn('');
1129
+ console.warn('To fix this, either:');
1130
+ console.warn(` 1. Install OpenSSL and restart (auto-generates certs)`);
1131
+ console.warn(` 2. Manually place certs in: ${CERT_DIR}`);
1132
+ console.warn('');
1133
+ } else {
1134
+ console.log('āœ… HTTPS enabled - ready for secure connections from deployed frontends');
1135
+ console.log('');
1136
+ }
1137
+ });