@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.
- package/daemon.mjs +88 -8
- 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.
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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"
|