@clastres/groundcontrol 0.1.3 → 0.1.5

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.
Files changed (2) hide show
  1. package/daemon.mjs +66 -5
  2. package/package.json +3 -3
package/daemon.mjs CHANGED
@@ -54,7 +54,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
54
54
  process.exit(0);
55
55
  }
56
56
  if (process.argv.includes('--version')) {
57
- console.log('0.1.3');
57
+ console.log('0.1.5');
58
58
  process.exit(0);
59
59
  }
60
60
 
@@ -88,6 +88,37 @@ function packageDirOf(dep) {
88
88
  return dir;
89
89
  }
90
90
 
91
+ /// A node path that survives upgrades. process.execPath resolves symlinks, so
92
+ /// on Homebrew it points into a versioned Cellar directory that the next
93
+ /// `brew upgrade node` deletes. Prefer a stable symlink that leads to the
94
+ /// same binary; fall back to the resolved path only when none does.
95
+ function stableNodePath() {
96
+ const real = fs.realpathSync(process.execPath);
97
+ for (const c of ['/opt/homebrew/opt/node/bin/node', '/opt/homebrew/bin/node', '/usr/local/bin/node']) {
98
+ try { if (fs.realpathSync(c) === real) return c; } catch {}
99
+ }
100
+ return real;
101
+ }
102
+
103
+ /// Explain why a loaded service is not running, with the fix.
104
+ function explainStoppedService(printOut) {
105
+ const uid = process.getuid();
106
+ const state = printOut.match(/state = (\S+)/)?.[1] || 'unknown';
107
+ const runs = printOut.match(/runs = (\d+)/)?.[1] || '0';
108
+ console.log(` Service state: ${state}, runs: ${runs}`);
109
+ const disabled = new RegExp(`"${LABEL}" => (disabled|true)`).test(launchctl(`print-disabled gui/${uid}`));
110
+ if (disabled) {
111
+ console.log(' The service is switched off in launchd, usually from System');
112
+ console.log(' Settings, General, Login Items. Turn node back on there, or run:');
113
+ console.log(` launchctl enable gui/${uid}/${LABEL} && launchctl kickstart -k gui/${uid}/${LABEL}`);
114
+ } else if (runs === '0') {
115
+ console.log(` It never started. Try: launchctl kickstart -k gui/${uid}/${LABEL}`);
116
+ console.log(` and check ${LOG_FILE}`);
117
+ } else {
118
+ console.log(` It ran and stopped. Check ${LOG_FILE} and ${path.join(os.homedir(), '.groundcontrol', 'daemon.err.log')}`);
119
+ }
120
+ }
121
+
91
122
  /// Stage a self contained copy of the daemon and register it with launchd.
92
123
  /// Returns true when launchd reports the job running.
93
124
  function installService() {
@@ -105,7 +136,7 @@ function installService() {
105
136
  }
106
137
 
107
138
  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
108
- const nodeBin = fs.realpathSync(process.execPath);
139
+ const nodeBin = stableNodePath();
109
140
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
110
141
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
111
142
  <plist version="1.0">
@@ -134,14 +165,23 @@ function installService() {
134
165
  `;
135
166
  fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true });
136
167
  fs.writeFileSync(PLIST_PATH, plist);
168
+ // A persisted disabled state (for example the Login Items toggle) makes
169
+ // bootstrap load the job without ever running it, so enable explicitly and
170
+ // force the first start instead of trusting RunAtLoad.
171
+ launchctl(`enable gui/${uid}/${LABEL}`);
137
172
  launchctl(`bootstrap gui/${uid} ${PLIST_PATH}`);
173
+ launchctl(`kickstart -k gui/${uid}/${LABEL}`);
138
174
  // launchd reports the job async; give it a moment to reach running state.
139
175
  let out = '';
140
176
  for (let i = 0; i < 5 && !out.includes('state = running'); i++) {
141
177
  execSync('sleep 1');
142
178
  out = launchctl(`print gui/${uid}/${LABEL}`);
143
179
  }
144
- return out.includes('state = running');
180
+ if (!out.includes('state = running')) {
181
+ explainStoppedService(out);
182
+ return false;
183
+ }
184
+ return true;
145
185
  }
146
186
 
147
187
  if (subcommand === 'service') {
@@ -180,7 +220,9 @@ if (subcommand === 'service') {
180
220
  if (out.includes('state = running')) {
181
221
  console.log('\n Running in the background.');
182
222
  } else if (fs.existsSync(PLIST_PATH)) {
183
- console.log('\n Installed but not running. Reinstall with: groundcontrol service install');
223
+ console.log('\n Installed but not running.');
224
+ explainStoppedService(out);
225
+ console.log(' Or reinstall with: groundcontrol service install');
184
226
  } else {
185
227
  console.log('\n Not installed. Set it up with: groundcontrol service install');
186
228
  }
@@ -251,6 +293,7 @@ if (subcommand !== 'run' && !runsFromServiceDir && process.platform === 'darwin'
251
293
  }
252
294
  console.log('\n Could not set up the background service, running here instead.');
253
295
  console.log(' Keep this window open to stay connected.\n');
296
+ try { fs.appendFileSync(LOG_FILE, '[daemon] background service failed to start at install, fell back to a foreground run\n'); } catch {}
254
297
  } else if (subcommand === 'run' || process.platform !== 'darwin') {
255
298
  console.log(' Keep this window open to stay connected.');
256
299
  if (process.platform === 'darwin') {
@@ -342,7 +385,14 @@ function terminalApps() {
342
385
  if (tty && id) tmuxPanes.set(tty, id);
343
386
  }
344
387
  } catch {}
388
+ const prev = termCache;
345
389
  termCache = { at: Date.now(), terminal: running('Terminal'), iterm: running('iTerm2'), tmuxPanes };
390
+ // The service usually starts at login, before any terminal app is open, so
391
+ // the startup Automation poke finds nothing. Poke again the first time a
392
+ // terminal app shows up, while the user is right there to click Allow.
393
+ if ((termCache.terminal && !prev.terminal) || (termCache.iterm && !prev.iterm)) {
394
+ requestAutomationGrant();
395
+ }
346
396
  return termCache;
347
397
  }
348
398
 
@@ -367,7 +417,10 @@ async function osascript(script) {
367
417
  /// Poke each running terminal app once at startup so the Automation dialog
368
418
  /// appears while the user is still at the Mac (the grant is keyed to this
369
419
  /// node binary and covers every later injection).
420
+ let lastGrantPokeAt = 0;
370
421
  async function requestAutomationGrant() {
422
+ if (Date.now() - lastGrantPokeAt < 10_000) return;
423
+ lastGrantPokeAt = Date.now();
371
424
  const apps = terminalApps();
372
425
  for (const [name, present] of [['Terminal', apps.terminal], ['iTerm2', apps.iterm]]) {
373
426
  if (!present) continue;
@@ -657,7 +710,10 @@ async function handleCommand(cmd) {
657
710
  let lastPongAt = Date.now();
658
711
 
659
712
  function connect() {
660
- ws = new WebSocket(`${RELAY}/ch/${CHANNEL}?role=host`);
713
+ // handshakeTimeout: a dial that never completes would otherwise sit in
714
+ // CONNECTING forever without firing close, freezing the retry loop.
715
+ ws = new WebSocket(`${RELAY}/ch/${CHANNEL}?role=host`, { handshakeTimeout: 15_000 });
716
+ lastPongAt = Date.now();
661
717
  ws.on('open', () => { lastPongAt = Date.now(); console.log('[daemon] connected to relay'); sendSnapshot(true); });
662
718
  ws.on('pong', () => { lastPongAt = Date.now(); });
663
719
  ws.on('message', (data) => {
@@ -690,6 +746,11 @@ setInterval(() => {
690
746
  console.log('[daemon] stale relay link, forcing reconnect');
691
747
  ws.terminate();
692
748
  }
749
+ } else if (ws && ws.readyState === 0 && Date.now() - lastPongAt > 65_000) {
750
+ // Backstop for a handshake stuck past its timeout; terminate fires the
751
+ // close handler, which schedules the next retry.
752
+ console.log('[daemon] connect attempt hung, forcing reconnect');
753
+ ws.terminate();
693
754
  }
694
755
  }, 20_000);
695
756
  try { fs.watch(SESSIONS_DIR, () => sendSnapshot()); } catch {}
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@clastres/groundcontrol",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "See and command your Claude Code sessions from your iPhone. Pairs the Ground Control app with the agents running on this Mac, end to end encrypted.",
5
5
  "bin": {
6
- "groundcontrol": "./bin/groundcontrol.mjs"
6
+ "groundcontrol": "bin/groundcontrol.mjs"
7
7
  },
8
8
  "type": "module",
9
9
  "engines": {
@@ -26,4 +26,4 @@
26
26
  "homepage": "https://groundcontrol.chat",
27
27
  "author": "Carlos Lastres",
28
28
  "license": "MIT"
29
- }
29
+ }