@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.
- package/daemon.mjs +66 -5
- 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.
|
|
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, '&').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
|
}
|
|
@@ -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
|
-
|
|
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
|
+
"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": "
|
|
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
|
+
}
|