@clastres/groundcontrol 0.1.2 → 0.1.4

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 +144 -11
  2. package/package.json +1 -1
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.2');
57
+ console.log('0.1.4');
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
  }
@@ -241,6 +283,8 @@ if (subcommand !== 'run' && !runsFromServiceDir && process.platform === 'darwin'
241
283
  if (installService()) {
242
284
  console.log('\n Done. The daemon now runs in the background: it starts at');
243
285
  console.log(' login and restarts by itself. You can close this window.\n');
286
+ console.log(' If macOS asks to allow "node" to control Terminal or iTerm2,');
287
+ console.log(' click Allow. That is how phone messages reach your sessions.\n');
244
288
  console.log(' Scan the QR above with the Ground Control app to pair.');
245
289
  console.log(' Show it again: npx @clastres/groundcontrol qr');
246
290
  console.log(' Check status: npx @clastres/groundcontrol service status');
@@ -249,6 +293,7 @@ if (subcommand !== 'run' && !runsFromServiceDir && process.platform === 'darwin'
249
293
  }
250
294
  console.log('\n Could not set up the background service, running here instead.');
251
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 {}
252
297
  } else if (subcommand === 'run' || process.platform !== 'darwin') {
253
298
  console.log(' Keep this window open to stay connected.');
254
299
  if (process.platform === 'darwin') {
@@ -277,6 +322,34 @@ function open(b64) {
277
322
  return JSON.parse(pt.toString('utf8'));
278
323
  }
279
324
 
325
+ // ---------- environment hardening ----------
326
+ // launchd starts the service with a bare PATH (/usr/bin:/bin), which hides
327
+ // tmux, claude, and anything from Homebrew or a version manager. Merge in the
328
+ // user's login shell PATH plus the usual install locations so the background
329
+ // daemon sees the same world a terminal does.
330
+
331
+ function widenPath() {
332
+ const extra = [];
333
+ try {
334
+ const shell = process.env.SHELL || '/bin/zsh';
335
+ extra.push(...execSync(`${shell} -lc 'echo $PATH'`, { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
336
+ .toString().trim().split(':'));
337
+ } catch {}
338
+ extra.push(
339
+ '/opt/homebrew/bin', '/usr/local/bin',
340
+ path.join(os.homedir(), '.local', 'bin'),
341
+ path.join(os.homedir(), '.claude', 'local'),
342
+ );
343
+ const seen = new Set((process.env.PATH || '').split(':').filter(Boolean));
344
+ for (const p of extra) if (p) seen.add(p);
345
+ process.env.PATH = [...seen].join(':');
346
+ }
347
+ widenPath();
348
+
349
+ let claudeBin = null;
350
+ try { claudeBin = execSync('command -v claude', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch {}
351
+ console.log(`[daemon] claude binary: ${claudeBin || 'NOT FOUND (phone prompts to idle sessions without a visible terminal will fail)'}`);
352
+
280
353
  // ---------- Claude Code session discovery ----------
281
354
 
282
355
  const CLAUDE = path.join(os.homedir(), '.claude');
@@ -312,18 +385,53 @@ function terminalApps() {
312
385
  if (tty && id) tmuxPanes.set(tty, id);
313
386
  }
314
387
  } catch {}
388
+ const prev = termCache;
315
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
+ }
316
396
  return termCache;
317
397
  }
318
398
 
319
399
  const escapeAS = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
320
400
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
321
401
 
402
+ // macOS requires a one time Automation grant before this process may control
403
+ // Terminal or iTerm2. A refusal surfaces as error -1743 on stderr; remember it
404
+ // so prompt failures can tell the phone exactly what to fix.
405
+ let automationDenied = false;
406
+
322
407
  async function osascript(script) {
323
408
  try {
324
409
  const { stdout } = await execFileP('osascript', ['-e', script]);
325
410
  return stdout.trim();
326
- } catch { return null; }
411
+ } catch (e) {
412
+ if (`${e.stderr || ''}${e.message || ''}`.includes('1743')) automationDenied = true;
413
+ return null;
414
+ }
415
+ }
416
+
417
+ /// Poke each running terminal app once at startup so the Automation dialog
418
+ /// appears while the user is still at the Mac (the grant is keyed to this
419
+ /// node binary and covers every later injection).
420
+ let lastGrantPokeAt = 0;
421
+ async function requestAutomationGrant() {
422
+ if (Date.now() - lastGrantPokeAt < 10_000) return;
423
+ lastGrantPokeAt = Date.now();
424
+ const apps = terminalApps();
425
+ for (const [name, present] of [['Terminal', apps.terminal], ['iTerm2', apps.iterm]]) {
426
+ if (!present) continue;
427
+ automationDenied = false;
428
+ const r = await osascript(`tell application "${name}" to count windows`);
429
+ if (r === null && automationDenied) {
430
+ console.log(`[daemon] macOS is blocking control of ${name}. Open System Settings,`);
431
+ console.log(`[daemon] Privacy and Security, Automation, and allow node to control ${name}.`);
432
+ console.log('[daemon] Phone messages cannot reach your terminal until you do.');
433
+ }
434
+ }
327
435
  }
328
436
 
329
437
  function findTabScript(app, dev, action) {
@@ -551,26 +659,50 @@ async function handleCommand(cmd) {
551
659
  sendSnapshot(true);
552
660
  return;
553
661
  }
662
+ if (automationDenied) {
663
+ console.log('[daemon] prompt blocked: macOS Automation permission missing');
664
+ return ws?.send(seal({
665
+ type: 'promptResult', sessionId: cmd.sessionId, ok: false,
666
+ error: 'Your Mac is blocking terminal control. Open System Settings, Privacy and Security, Automation, and allow node to control your terminal app. Then send again.',
667
+ }));
668
+ }
669
+ }
670
+ if (session.status === 'busy') return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session is busy, wait for it to go idle' }));
671
+ if (!claudeBin) {
672
+ console.log('[daemon] prompt failed: claude binary not found on PATH');
673
+ return ws?.send(seal({
674
+ type: 'promptResult', sessionId: cmd.sessionId, ok: false,
675
+ error: 'The daemon could not find the claude command on this Mac. Open a terminal, check that claude runs, then reinstall with: npx @clastres/groundcontrol',
676
+ }));
554
677
  }
555
- if (session.status === 'busy') return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session is busy — wait for it to go idle' }));
556
678
  console.log(`[daemon] prompt -> ${session.name}: ${cmd.text.slice(0, 60)}`);
557
679
  headlessActive.add(cmd.sessionId);
558
680
  sendSnapshot(true);
559
- const child = spawn('claude', ['--resume', cmd.sessionId, '-p', cmd.text], {
681
+ const child = spawn(claudeBin, ['--resume', cmd.sessionId, '-p', cmd.text], {
560
682
  cwd: session.cwd || os.homedir(),
561
683
  env: process.env,
562
684
  stdio: ['ignore', 'pipe', 'pipe'],
563
685
  });
564
686
  let out = '', err = '';
687
+ let responded = false;
688
+ const respond = (payload) => {
689
+ if (responded) return;
690
+ responded = true;
691
+ headlessActive.delete(cmd.sessionId);
692
+ ws?.send(seal(payload));
693
+ sendSnapshot(true);
694
+ };
565
695
  child.stdout.on('data', (d) => { out += d; });
566
696
  child.stderr.on('data', (d) => { err += d; });
697
+ child.on('error', (e) => {
698
+ console.log(`[daemon] claude spawn failed: ${e.message}`);
699
+ respond({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: `could not run claude: ${e.message}` });
700
+ });
567
701
  child.on('close', (code) => {
568
- headlessActive.delete(cmd.sessionId);
569
- ws?.send(seal({
702
+ respond({
570
703
  type: 'promptResult', sessionId: cmd.sessionId, ok: code === 0,
571
704
  text: (out || err).trim().slice(0, 4000),
572
- }));
573
- sendSnapshot(true);
705
+ });
574
706
  });
575
707
  }
576
708
  }
@@ -599,6 +731,7 @@ function connect() {
599
731
  }
600
732
 
601
733
  connect();
734
+ if (process.platform === 'darwin') requestAutomationGrant();
602
735
  setInterval(sendSnapshot, 1500);
603
736
  // Heartbeat traffic keeps the Cloudflare socket alive in both directions and
604
737
  // lets the phone detect a dead link; missing pongs force a reconnect here.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clastres/groundcontrol",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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
6
  "groundcontrol": "./bin/groundcontrol.mjs"