@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.
- package/daemon.mjs +144 -11
- 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.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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
108
|
-
const nodeBin =
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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"
|