@clastres/groundcontrol 0.1.2 → 0.1.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.
Files changed (2) hide show
  1. package/daemon.mjs +88 -8
  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.3');
58
58
  process.exit(0);
59
59
  }
60
60
 
@@ -241,6 +241,8 @@ if (subcommand !== 'run' && !runsFromServiceDir && process.platform === 'darwin'
241
241
  if (installService()) {
242
242
  console.log('\n Done. The daemon now runs in the background: it starts at');
243
243
  console.log(' login and restarts by itself. You can close this window.\n');
244
+ console.log(' If macOS asks to allow "node" to control Terminal or iTerm2,');
245
+ console.log(' click Allow. That is how phone messages reach your sessions.\n');
244
246
  console.log(' Scan the QR above with the Ground Control app to pair.');
245
247
  console.log(' Show it again: npx @clastres/groundcontrol qr');
246
248
  console.log(' Check status: npx @clastres/groundcontrol service status');
@@ -277,6 +279,34 @@ function open(b64) {
277
279
  return JSON.parse(pt.toString('utf8'));
278
280
  }
279
281
 
282
+ // ---------- environment hardening ----------
283
+ // launchd starts the service with a bare PATH (/usr/bin:/bin), which hides
284
+ // tmux, claude, and anything from Homebrew or a version manager. Merge in the
285
+ // user's login shell PATH plus the usual install locations so the background
286
+ // daemon sees the same world a terminal does.
287
+
288
+ function widenPath() {
289
+ const extra = [];
290
+ try {
291
+ const shell = process.env.SHELL || '/bin/zsh';
292
+ extra.push(...execSync(`${shell} -lc 'echo $PATH'`, { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
293
+ .toString().trim().split(':'));
294
+ } catch {}
295
+ extra.push(
296
+ '/opt/homebrew/bin', '/usr/local/bin',
297
+ path.join(os.homedir(), '.local', 'bin'),
298
+ path.join(os.homedir(), '.claude', 'local'),
299
+ );
300
+ const seen = new Set((process.env.PATH || '').split(':').filter(Boolean));
301
+ for (const p of extra) if (p) seen.add(p);
302
+ process.env.PATH = [...seen].join(':');
303
+ }
304
+ widenPath();
305
+
306
+ let claudeBin = null;
307
+ try { claudeBin = execSync('command -v claude', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch {}
308
+ console.log(`[daemon] claude binary: ${claudeBin || 'NOT FOUND (phone prompts to idle sessions without a visible terminal will fail)'}`);
309
+
280
310
  // ---------- Claude Code session discovery ----------
281
311
 
282
312
  const CLAUDE = path.join(os.homedir(), '.claude');
@@ -319,11 +349,36 @@ function terminalApps() {
319
349
  const escapeAS = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
320
350
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
321
351
 
352
+ // macOS requires a one time Automation grant before this process may control
353
+ // Terminal or iTerm2. A refusal surfaces as error -1743 on stderr; remember it
354
+ // so prompt failures can tell the phone exactly what to fix.
355
+ let automationDenied = false;
356
+
322
357
  async function osascript(script) {
323
358
  try {
324
359
  const { stdout } = await execFileP('osascript', ['-e', script]);
325
360
  return stdout.trim();
326
- } catch { return null; }
361
+ } catch (e) {
362
+ if (`${e.stderr || ''}${e.message || ''}`.includes('1743')) automationDenied = true;
363
+ return null;
364
+ }
365
+ }
366
+
367
+ /// Poke each running terminal app once at startup so the Automation dialog
368
+ /// appears while the user is still at the Mac (the grant is keyed to this
369
+ /// node binary and covers every later injection).
370
+ async function requestAutomationGrant() {
371
+ const apps = terminalApps();
372
+ for (const [name, present] of [['Terminal', apps.terminal], ['iTerm2', apps.iterm]]) {
373
+ if (!present) continue;
374
+ automationDenied = false;
375
+ const r = await osascript(`tell application "${name}" to count windows`);
376
+ if (r === null && automationDenied) {
377
+ console.log(`[daemon] macOS is blocking control of ${name}. Open System Settings,`);
378
+ console.log(`[daemon] Privacy and Security, Automation, and allow node to control ${name}.`);
379
+ console.log('[daemon] Phone messages cannot reach your terminal until you do.');
380
+ }
381
+ }
327
382
  }
328
383
 
329
384
  function findTabScript(app, dev, action) {
@@ -551,26 +606,50 @@ async function handleCommand(cmd) {
551
606
  sendSnapshot(true);
552
607
  return;
553
608
  }
609
+ if (automationDenied) {
610
+ console.log('[daemon] prompt blocked: macOS Automation permission missing');
611
+ return ws?.send(seal({
612
+ type: 'promptResult', sessionId: cmd.sessionId, ok: false,
613
+ 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.',
614
+ }));
615
+ }
616
+ }
617
+ 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' }));
618
+ if (!claudeBin) {
619
+ console.log('[daemon] prompt failed: claude binary not found on PATH');
620
+ return ws?.send(seal({
621
+ type: 'promptResult', sessionId: cmd.sessionId, ok: false,
622
+ 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',
623
+ }));
554
624
  }
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
625
  console.log(`[daemon] prompt -> ${session.name}: ${cmd.text.slice(0, 60)}`);
557
626
  headlessActive.add(cmd.sessionId);
558
627
  sendSnapshot(true);
559
- const child = spawn('claude', ['--resume', cmd.sessionId, '-p', cmd.text], {
628
+ const child = spawn(claudeBin, ['--resume', cmd.sessionId, '-p', cmd.text], {
560
629
  cwd: session.cwd || os.homedir(),
561
630
  env: process.env,
562
631
  stdio: ['ignore', 'pipe', 'pipe'],
563
632
  });
564
633
  let out = '', err = '';
634
+ let responded = false;
635
+ const respond = (payload) => {
636
+ if (responded) return;
637
+ responded = true;
638
+ headlessActive.delete(cmd.sessionId);
639
+ ws?.send(seal(payload));
640
+ sendSnapshot(true);
641
+ };
565
642
  child.stdout.on('data', (d) => { out += d; });
566
643
  child.stderr.on('data', (d) => { err += d; });
644
+ child.on('error', (e) => {
645
+ console.log(`[daemon] claude spawn failed: ${e.message}`);
646
+ respond({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: `could not run claude: ${e.message}` });
647
+ });
567
648
  child.on('close', (code) => {
568
- headlessActive.delete(cmd.sessionId);
569
- ws?.send(seal({
649
+ respond({
570
650
  type: 'promptResult', sessionId: cmd.sessionId, ok: code === 0,
571
651
  text: (out || err).trim().slice(0, 4000),
572
- }));
573
- sendSnapshot(true);
652
+ });
574
653
  });
575
654
  }
576
655
  }
@@ -599,6 +678,7 @@ function connect() {
599
678
  }
600
679
 
601
680
  connect();
681
+ if (process.platform === 'darwin') requestAutomationGrant();
602
682
  setInterval(sendSnapshot, 1500);
603
683
  // Heartbeat traffic keeps the Cloudflare socket alive in both directions and
604
684
  // 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.3",
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"