knoxis-helper 1.1.0 → 1.2.1

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.
@@ -3,20 +3,35 @@
3
3
  /**
4
4
  * Knoxis Helper - First-time setup + local agent launcher
5
5
  *
6
+ * On first run (typically via npx), this copies the agent files to a stable
7
+ * local path (~/.knoxis/agent/) so that:
8
+ * 1. Subsequent runs don't need network (works behind VPN)
9
+ * 2. The macOS LaunchAgent always points to a path that exists
10
+ * 3. Coworkers get the same reliable experience
11
+ *
6
12
  * Usage:
7
- * npx knoxis-helper # Interactive setup (first time)
8
- * npx knoxis-helper --backend wss://... # Connect to specific backend
9
- * npx knoxis-helper --status # Check connection status
13
+ * npx knoxis-helper # First-time: setup + install locally
14
+ * npx knoxis-helper --backend wss://... # Connect to specific backend
15
+ * npx knoxis-helper --status # Check connection status
16
+ * npx knoxis-helper --check # Diagnose connectivity issues
17
+ * npx knoxis-helper --update # Force re-copy of agent files
18
+ * npx knoxis-helper --uninstall # Remove LaunchAgent + local files
10
19
  */
11
20
 
12
21
  const fs = require('fs');
13
22
  const path = require('path');
14
23
  const os = require('os');
24
+ const https = require('https');
25
+ const http = require('http');
15
26
  const readline = require('readline');
27
+ const { spawnSync } = require('child_process');
16
28
 
17
29
  const CONFIG_DIR = path.join(os.homedir(), '.knoxis');
18
30
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
19
- const DEFAULT_BACKEND = 'wss://voice-backend.qig.ai';
31
+ const AGENT_DIR = path.join(CONFIG_DIR, 'agent');
32
+ const STABLE_AGENT_PATH = path.join(AGENT_DIR, 'knoxis-local-agent.js');
33
+ const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.knoxis.helper.plist');
34
+ const DEFAULT_BACKEND = 'wss://voice-app-v2.graymushroom-5a5599fe.centralus.azurecontainerapps.io';
20
35
 
21
36
  function loadConfig() {
22
37
  try {
@@ -40,6 +55,9 @@ function parseArgs() {
40
55
  const arg = process.argv[i];
41
56
  if (arg === '--status') { args.status = true; continue; }
42
57
  if (arg === '--reset') { args.reset = true; continue; }
58
+ if (arg === '--check') { args.check = true; continue; }
59
+ if (arg === '--update') { args.update = true; continue; }
60
+ if (arg === '--uninstall') { args.uninstall = true; continue; }
43
61
  if (arg.startsWith('--')) {
44
62
  const key = arg.slice(2);
45
63
  const next = process.argv[i + 1];
@@ -58,6 +76,118 @@ function ask(rl, question) {
58
76
  return new Promise(resolve => rl.question(question, resolve));
59
77
  }
60
78
 
79
+ /**
80
+ * Copy agent files to ~/.knoxis/agent/ so they persist across npx cache clears,
81
+ * VPN changes, and reboots. Returns the stable path.
82
+ */
83
+ function installAgentLocally(force) {
84
+ const sourceAgent = path.join(__dirname, '..', 'lib', 'knoxis-local-agent.js');
85
+ const sourcePairProgram = path.join(__dirname, '..', 'lib', 'knoxis-pair-program.js');
86
+ const sourcePackage = path.join(__dirname, '..', 'package.json');
87
+
88
+ if (!fs.existsSync(sourceAgent)) {
89
+ return null; // Source not available (shouldn't happen)
90
+ }
91
+
92
+ // Check if already installed and up to date
93
+ if (!force && fs.existsSync(STABLE_AGENT_PATH)) {
94
+ try {
95
+ const installedPkg = path.join(AGENT_DIR, 'package.json');
96
+ if (fs.existsSync(installedPkg) && fs.existsSync(sourcePackage)) {
97
+ const installed = JSON.parse(fs.readFileSync(installedPkg, 'utf8'));
98
+ const source = JSON.parse(fs.readFileSync(sourcePackage, 'utf8'));
99
+ if (installed.version === source.version) {
100
+ return STABLE_AGENT_PATH; // Already up to date
101
+ }
102
+ }
103
+ } catch (e) {
104
+ // Fall through to install
105
+ }
106
+ }
107
+
108
+ // Copy files to stable location
109
+ if (!fs.existsSync(AGENT_DIR)) {
110
+ fs.mkdirSync(AGENT_DIR, { recursive: true });
111
+ }
112
+
113
+ fs.copyFileSync(sourceAgent, STABLE_AGENT_PATH);
114
+ console.log(' Installed: knoxis-local-agent.js');
115
+
116
+ if (fs.existsSync(sourcePairProgram)) {
117
+ fs.copyFileSync(sourcePairProgram, path.join(AGENT_DIR, 'knoxis-pair-program.js'));
118
+ console.log(' Installed: knoxis-pair-program.js');
119
+ }
120
+
121
+ if (fs.existsSync(sourcePackage)) {
122
+ fs.copyFileSync(sourcePackage, path.join(AGENT_DIR, 'package.json'));
123
+ }
124
+
125
+ return STABLE_AGENT_PATH;
126
+ }
127
+
128
+ /**
129
+ * Check if the backend is reachable (helps diagnose VPN issues)
130
+ */
131
+ function checkConnectivity(backendUrl) {
132
+ return new Promise((resolve) => {
133
+ // Convert wss:// to https:// for health check
134
+ let healthUrl = backendUrl
135
+ .replace('wss://', 'https://')
136
+ .replace('ws://', 'http://');
137
+ // Strip path and add /health (using the local agent's health, not backend)
138
+ // Actually check the backend WebSocket endpoint reachability
139
+ if (!healthUrl.endsWith('/')) healthUrl += '/';
140
+
141
+ const client = healthUrl.startsWith('https') ? https : http;
142
+ const req = client.get(healthUrl, { rejectUnauthorized: false, timeout: 5000 }, (res) => {
143
+ resolve({ reachable: true, statusCode: res.statusCode });
144
+ });
145
+ req.on('error', (err) => {
146
+ resolve({ reachable: false, error: err.code || err.message });
147
+ });
148
+ req.on('timeout', () => {
149
+ req.destroy();
150
+ resolve({ reachable: false, error: 'TIMEOUT' });
151
+ });
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Check if the local agent is already running
157
+ */
158
+ function checkLocalAgent(port) {
159
+ return new Promise((resolve) => {
160
+ const req = https.get(`https://localhost:${port || 3456}/health`, { rejectUnauthorized: false, timeout: 3000 }, (res) => {
161
+ let data = '';
162
+ res.on('data', chunk => { data += chunk; });
163
+ res.on('end', () => {
164
+ try {
165
+ resolve({ running: true, ...JSON.parse(data) });
166
+ } catch (e) {
167
+ resolve({ running: true, raw: data });
168
+ }
169
+ });
170
+ });
171
+ req.on('error', () => {
172
+ // Try HTTP fallback
173
+ const req2 = http.get(`http://localhost:${port || 3456}/health`, { timeout: 3000 }, (res) => {
174
+ let data = '';
175
+ res.on('data', chunk => { data += chunk; });
176
+ res.on('end', () => {
177
+ try {
178
+ resolve({ running: true, ...JSON.parse(data) });
179
+ } catch (e) {
180
+ resolve({ running: true, raw: data });
181
+ }
182
+ });
183
+ });
184
+ req2.on('error', () => resolve({ running: false }));
185
+ req2.on('timeout', () => { req2.destroy(); resolve({ running: false }); });
186
+ });
187
+ req.on('timeout', () => { req.destroy(); resolve({ running: false }); });
188
+ });
189
+ }
190
+
61
191
  async function firstTimeSetup(args) {
62
192
  const config = loadConfig();
63
193
 
@@ -119,9 +249,37 @@ async function firstTimeSetup(args) {
119
249
  return config;
120
250
  }
121
251
 
252
+ function uninstall() {
253
+ console.log('');
254
+ console.log(' Knoxis Helper - Uninstall');
255
+ console.log(' =========================');
256
+
257
+ // Unload and remove LaunchAgent
258
+ if (process.platform === 'darwin' && fs.existsSync(PLIST_PATH)) {
259
+ spawnSync('launchctl', ['unload', PLIST_PATH]);
260
+ fs.unlinkSync(PLIST_PATH);
261
+ console.log(' Removed LaunchAgent');
262
+ }
263
+
264
+ // Remove agent files
265
+ if (fs.existsSync(AGENT_DIR)) {
266
+ fs.rmSync(AGENT_DIR, { recursive: true, force: true });
267
+ console.log(' Removed ~/.knoxis/agent/');
268
+ }
269
+
270
+ console.log(' Config preserved at ~/.knoxis/config.json (use --reset to clear)');
271
+ console.log('');
272
+ }
273
+
122
274
  async function main() {
123
275
  const args = parseArgs();
124
276
 
277
+ // Uninstall
278
+ if (args.uninstall) {
279
+ uninstall();
280
+ process.exit(0);
281
+ }
282
+
125
283
  // Reset
126
284
  if (args.reset) {
127
285
  try { fs.unlinkSync(CONFIG_FILE); } catch (e) {}
@@ -136,9 +294,83 @@ async function main() {
136
294
  console.log('Not configured. Run: npx knoxis-helper');
137
295
  } else {
138
296
  console.log('Configured:');
139
- console.log(` Backend: ${config.backendWsUrl}`);
140
- console.log(` User: ${config.userId}`);
297
+ console.log(` Backend: ${config.backendWsUrl}`);
298
+ console.log(` User: ${config.userId}`);
299
+ console.log(` Installed: ${fs.existsSync(STABLE_AGENT_PATH) ? STABLE_AGENT_PATH : 'NOT INSTALLED (run npx knoxis-helper --update)'}`);
300
+
301
+ const local = await checkLocalAgent();
302
+ console.log(` Running: ${local.running ? 'YES (v' + (local.version || '?') + ', ' + (local.secure ? 'HTTPS' : 'HTTP') + ')' : 'NO'}`);
303
+ }
304
+ process.exit(0);
305
+ }
306
+
307
+ // Connectivity check
308
+ if (args.check) {
309
+ const config = loadConfig();
310
+ console.log('');
311
+ console.log(' Knoxis Helper - Connectivity Check');
312
+ console.log(' ==================================');
313
+ console.log('');
314
+
315
+ // Check local agent
316
+ const local = await checkLocalAgent();
317
+ if (local.running) {
318
+ console.log(` Local agent: RUNNING (v${local.version || '?'}, ${local.secure ? 'HTTPS' : 'HTTP'})`);
319
+ } else {
320
+ console.log(' Local agent: NOT RUNNING');
321
+ }
322
+
323
+ // Check stable install
324
+ console.log(` Installed at: ${fs.existsSync(STABLE_AGENT_PATH) ? STABLE_AGENT_PATH : 'NOT INSTALLED'}`);
325
+
326
+ // Check LaunchAgent
327
+ if (process.platform === 'darwin') {
328
+ console.log(` LaunchAgent: ${fs.existsSync(PLIST_PATH) ? 'CONFIGURED' : 'NOT CONFIGURED'}`);
141
329
  }
330
+
331
+ // Check backend reachability
332
+ if (config.backendWsUrl) {
333
+ console.log(` Backend URL: ${config.backendWsUrl}`);
334
+ const result = await checkConnectivity(config.backendWsUrl);
335
+ if (result.reachable) {
336
+ console.log(` Backend: REACHABLE (HTTP ${result.statusCode})`);
337
+ } else {
338
+ console.log(` Backend: UNREACHABLE (${result.error})`);
339
+ console.log('');
340
+ if (result.error === 'TIMEOUT' || result.error === 'ECONNREFUSED') {
341
+ console.log(' This is likely a VPN issue. The backend is not reachable from this network.');
342
+ console.log(' The local agent will still work for direct API calls (localhost:3456).');
343
+ console.log(' WebSocket relay to backend will reconnect automatically when VPN allows it.');
344
+ } else {
345
+ console.log(` Network error: ${result.error}`);
346
+ console.log(' Check your VPN settings or try: curl -k https://voice-app-v2.graymushroom-5a5599fe.centralus.azurecontainerapps.io/');
347
+ }
348
+ }
349
+ } else {
350
+ console.log(' Backend: NOT CONFIGURED');
351
+ }
352
+
353
+ // Check npm registry (the actual cause of "reinstall" prompts)
354
+ console.log('');
355
+ console.log(' Checking npm registry access...');
356
+ const npmResult = await checkConnectivity('https://registry.npmjs.org/knoxis-helper');
357
+ if (npmResult.reachable) {
358
+ console.log(' npm registry: REACHABLE');
359
+ } else {
360
+ console.log(` npm registry: UNREACHABLE (${npmResult.error})`);
361
+ console.log('');
362
+ console.log(' This is why npx keeps prompting to reinstall!');
363
+ console.log(' When npm registry is unreachable (VPN), npx can\'t verify its cache.');
364
+ console.log(' Fix: The local install at ~/.knoxis/agent/ bypasses this entirely.');
365
+ if (!fs.existsSync(STABLE_AGENT_PATH)) {
366
+ console.log(' Run: npx knoxis-helper (while OFF VPN to do initial install)');
367
+ } else {
368
+ console.log(' Your local install is fine. Run directly with:');
369
+ console.log(` node ${STABLE_AGENT_PATH}`);
370
+ }
371
+ }
372
+
373
+ console.log('');
142
374
  process.exit(0);
143
375
  }
144
376
 
@@ -155,13 +387,24 @@ async function main() {
155
387
  console.log('');
156
388
  }
157
389
 
390
+ // Install agent files to stable path (~/.knoxis/agent/)
391
+ // This is the key fix: npx cache paths are ephemeral, this path is permanent
392
+ const stableAgent = installAgentLocally(!!args.update);
393
+ if (stableAgent) {
394
+ console.log(` Agent installed at: ${stableAgent}`);
395
+ console.log(' (This path survives VPN changes, npx cache clears, and reboots)');
396
+ console.log('');
397
+ }
398
+
158
399
  // Set environment variables for the local agent
159
400
  process.env.KNOXIS_BACKEND_WS_URL = config.backendWsUrl;
160
401
  process.env.KNOXIS_USER_ID = config.userId;
402
+ // Tell the agent to use the stable path for its LaunchAgent plist
403
+ process.env.KNOXIS_STABLE_AGENT_PATH = STABLE_AGENT_PATH;
161
404
 
162
- // Launch the local agent
163
- // Try to find it in common locations
405
+ // Determine which agent to load: prefer stable install, fall back to package location
164
406
  const agentLocations = [
407
+ STABLE_AGENT_PATH,
165
408
  path.join(__dirname, '..', 'lib', 'knoxis-local-agent.js'),
166
409
  path.join(__dirname, '..', '..', 'knoxis-local-agent.js'),
167
410
  path.join(process.cwd(), 'scripts', 'knoxis-local-agent.js'),
@@ -446,7 +446,7 @@ async function handleRequest(req, res) {
446
446
  status: 'healthy',
447
447
  platform: os.platform(),
448
448
  agent: 'knoxis-local-agent',
449
- version: '2.1.0-https',
449
+ version: '2.2.0-stable',
450
450
  secure: serverMeta.secure,
451
451
  port: serverMeta.port,
452
452
  dependencies: 'none',
@@ -864,7 +864,13 @@ function ensureLaunchAgentIfNeeded() {
864
864
  }
865
865
 
866
866
  const plistPath = path.join(agentsDir, 'com.knoxis.helper.plist');
867
- const programArgs = [process.execPath, __filename];
867
+
868
+ // Use the stable path (~/.knoxis/agent/) if available, NOT the ephemeral npx cache path.
869
+ // This is critical: npx cache paths change on every download, breaking the LaunchAgent.
870
+ const stableAgentPath = process.env.KNOXIS_STABLE_AGENT_PATH
871
+ || path.join(os.homedir(), '.knoxis', 'agent', 'knoxis-local-agent.js');
872
+ const agentScript = fs.existsSync(stableAgentPath) ? stableAgentPath : __filename;
873
+ const programArgs = [process.execPath, agentScript];
868
874
 
869
875
  const logDir = path.join(os.homedir(), 'Library', 'Logs');
870
876
  if (!fs.existsSync(logDir)) {
@@ -904,7 +910,7 @@ function ensureLaunchAgentIfNeeded() {
904
910
  spawnSync('launchctl', ['unload', plistPath]);
905
911
  const load = spawnSync('launchctl', ['load', '-w', plistPath]);
906
912
  if (load.status === 0) {
907
- console.log('🛠️ Installed launch agent to auto-start Knoxis helper');
913
+ console.log(`🛠️ Installed launch agent (pointing to ${agentScript})`);
908
914
  } else {
909
915
  console.warn('⚠️ Unable to automatically load launch agent. Run:');
910
916
  console.warn(` launchctl load -w ${plistPath}`);
@@ -934,10 +940,12 @@ function loadConfigFallback() {
934
940
  const _config = loadConfigFallback();
935
941
  const BACKEND_WS_URL = process.env.KNOXIS_BACKEND_WS_URL || _config.backendWsUrl || null;
936
942
  const RELAY_USER_ID = process.env.KNOXIS_USER_ID || _config.userId || null;
937
- const RELAY_RECONNECT_INTERVAL_MS = parseInt(process.env.KNOXIS_RECONNECT_MS || '10000', 10);
943
+ const RELAY_RECONNECT_BASE_MS = parseInt(process.env.KNOXIS_RECONNECT_MS || '2000', 10);
944
+ const RELAY_RECONNECT_MAX_MS = 60000; // Cap at 60s
938
945
 
939
946
  let relaySocket = null;
940
947
  let relayReconnectTimer = null;
948
+ let relayReconnectAttempts = 0;
941
949
 
942
950
  function connectRelayWebSocket() {
943
951
  if (!BACKEND_WS_URL || !RELAY_USER_ID) {
@@ -972,6 +980,7 @@ function connectRelayWebSocket() {
972
980
 
973
981
  relaySocket.onopen = () => {
974
982
  console.log('✅ Relay WebSocket connected to backend');
983
+ relayReconnectAttempts = 0; // Reset backoff on successful connection
975
984
  if (relayReconnectTimer) {
976
985
  clearTimeout(relayReconnectTimer);
977
986
  relayReconnectTimer = null;
@@ -1036,9 +1045,16 @@ function connectRelayWebSocket() {
1036
1045
  };
1037
1046
 
1038
1047
  relaySocket.onclose = (event) => {
1039
- console.log(`🔌 Relay WebSocket closed (code: ${event.code || 'unknown'})`);
1048
+ const code = event.code || 'unknown';
1049
+ console.log(`🔌 Relay WebSocket closed (code: ${code})`);
1040
1050
  relaySocket = null;
1041
- scheduleRelayReconnect();
1051
+ // Abnormal closure (1006) or going away (1001) — reconnect immediately
1052
+ if (code === 1006 || code === 1001) {
1053
+ console.log('⚡ Unexpected close — reconnecting immediately...');
1054
+ setTimeout(() => connectRelayWebSocket(), 500);
1055
+ } else {
1056
+ scheduleRelayReconnect();
1057
+ }
1042
1058
  };
1043
1059
 
1044
1060
  relaySocket.onerror = (err) => {
@@ -1051,11 +1067,24 @@ function scheduleRelayReconnect() {
1051
1067
  if (relayReconnectTimer) return;
1052
1068
  if (!BACKEND_WS_URL || !RELAY_USER_ID) return;
1053
1069
 
1054
- console.log(`⏳ Relay reconnecting in ${RELAY_RECONNECT_INTERVAL_MS / 1000}s...`);
1070
+ // Exponential backoff: 2s, 4s, 8s, 16s, 32s, 60s (capped)
1071
+ // This prevents log spam when VPN blocks the backend
1072
+ relayReconnectAttempts++;
1073
+ const delay = Math.min(RELAY_RECONNECT_BASE_MS * Math.pow(2, relayReconnectAttempts - 1), RELAY_RECONNECT_MAX_MS);
1074
+ const delaySec = (delay / 1000).toFixed(0);
1075
+
1076
+ if (relayReconnectAttempts <= 3) {
1077
+ console.log(`⏳ Relay reconnecting in ${delaySec}s... (attempt ${relayReconnectAttempts})`);
1078
+ } else if (relayReconnectAttempts === 4) {
1079
+ console.log(`⏳ Relay reconnecting in ${delaySec}s... (backend may be unreachable — VPN?)`);
1080
+ console.log(' Local agent (localhost:3456) still works. Relay will keep retrying quietly.');
1081
+ }
1082
+ // After attempt 4, reconnect silently to avoid log noise
1083
+
1055
1084
  relayReconnectTimer = setTimeout(() => {
1056
1085
  relayReconnectTimer = null;
1057
1086
  connectRelayWebSocket();
1058
- }, RELAY_RECONNECT_INTERVAL_MS);
1087
+ }, delay);
1059
1088
  }
1060
1089
 
1061
1090
  // Send heartbeat to keep relay alive
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "knoxis-helper",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Local helper for Knoxis pair programming - connects your machine to Knoxis on qig.ai",
5
5
  "bin": {
6
6
  "knoxis-helper": "./bin/knoxis-helper.js"
7
7
  },
8
- "keywords": ["knoxis", "pair-programming", "claude", "qig"],
8
+ "keywords": [
9
+ "knoxis",
10
+ "pair-programming",
11
+ "claude",
12
+ "qig"
13
+ ],
9
14
  "license": "MIT",
10
15
  "engines": {
11
16
  "node": ">=18"