@clastres/groundcontrol 0.1.0 → 0.1.1
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/README.md +1 -1
- package/daemon.mjs +126 -3
- package/package.json +18 -5
package/README.md
CHANGED
package/daemon.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import os from 'node:os';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { spawn, execFile, execSync } from 'node:child_process';
|
|
17
17
|
import { promisify } from 'node:util';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
18
19
|
|
|
19
20
|
const execFileP = promisify(execFile);
|
|
20
21
|
|
|
@@ -24,12 +25,20 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
24
25
|
console.log(`
|
|
25
26
|
GROUND CONTROL daemon
|
|
26
27
|
|
|
27
|
-
Usage: groundcontrol [options]
|
|
28
|
+
Usage: groundcontrol [command] [options]
|
|
28
29
|
|
|
29
30
|
Shows the Claude Code sessions on this Mac in the Ground Control iPhone app.
|
|
30
31
|
On first run it generates a pairing key and prints a QR code. Scan it with
|
|
31
32
|
the app and you are connected. No account needed.
|
|
32
33
|
|
|
34
|
+
Commands:
|
|
35
|
+
(none) Run the daemon in this terminal
|
|
36
|
+
service install Keep the daemon running in the background, always.
|
|
37
|
+
Starts at login and restarts if it ever stops.
|
|
38
|
+
service uninstall Remove the background service
|
|
39
|
+
service status Check whether the background service is running
|
|
40
|
+
qr Print the pairing QR code and exit
|
|
41
|
+
|
|
33
42
|
Options:
|
|
34
43
|
--relay <url> Use a custom relay (default: wss://relay.groundcontrol.chat)
|
|
35
44
|
--version Print version
|
|
@@ -41,10 +50,112 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
41
50
|
process.exit(0);
|
|
42
51
|
}
|
|
43
52
|
if (process.argv.includes('--version')) {
|
|
44
|
-
console.log('0.1.
|
|
53
|
+
console.log('0.1.1');
|
|
45
54
|
process.exit(0);
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
// ---------- background service (launchd) ----------
|
|
58
|
+
// `groundcontrol service install` keeps the daemon alive with no terminal:
|
|
59
|
+
// starts at login, restarts on crash, survives everything short of logout.
|
|
60
|
+
|
|
61
|
+
const subcommand = process.argv[2];
|
|
62
|
+
if (subcommand === 'service') {
|
|
63
|
+
const action = process.argv[3];
|
|
64
|
+
const LABEL = 'chat.groundcontrol.daemon';
|
|
65
|
+
const confDir = path.join(os.homedir(), '.groundcontrol');
|
|
66
|
+
const logFile = path.join(confDir, 'daemon.log');
|
|
67
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
68
|
+
if (process.platform !== 'darwin') {
|
|
69
|
+
console.log('\n The background service is macOS only for now. Keep the daemon');
|
|
70
|
+
console.log(' running in a terminal, tmux, or your own process manager.\n');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const uid = process.getuid();
|
|
74
|
+
const launchctl = (args) => {
|
|
75
|
+
try { return execSync(`launchctl ${args}`, { stdio: 'pipe' }).toString(); }
|
|
76
|
+
catch (e) { return (e.stdout?.toString() || '') + (e.stderr?.toString() || ''); }
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (action === 'install') {
|
|
80
|
+
fs.mkdirSync(confDir, { recursive: true });
|
|
81
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
82
|
+
const nodeBin = fs.realpathSync(process.execPath);
|
|
83
|
+
const daemonPath = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
84
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
85
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
86
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
87
|
+
<plist version="1.0">
|
|
88
|
+
<dict>
|
|
89
|
+
<key>Label</key>
|
|
90
|
+
<string>${LABEL}</string>
|
|
91
|
+
<key>ProgramArguments</key>
|
|
92
|
+
<array>
|
|
93
|
+
<string>${esc(nodeBin)}</string>
|
|
94
|
+
<string>${esc(daemonPath)}</string>
|
|
95
|
+
</array>
|
|
96
|
+
<key>WorkingDirectory</key>
|
|
97
|
+
<string>${esc(path.dirname(daemonPath))}</string>
|
|
98
|
+
<key>RunAtLoad</key>
|
|
99
|
+
<true/>
|
|
100
|
+
<key>KeepAlive</key>
|
|
101
|
+
<true/>
|
|
102
|
+
<key>ThrottleInterval</key>
|
|
103
|
+
<integer>5</integer>
|
|
104
|
+
<key>StandardOutPath</key>
|
|
105
|
+
<string>${esc(logFile)}</string>
|
|
106
|
+
<key>StandardErrorPath</key>
|
|
107
|
+
<string>${esc(path.join(confDir, 'daemon.err.log'))}</string>
|
|
108
|
+
</dict>
|
|
109
|
+
</plist>
|
|
110
|
+
`;
|
|
111
|
+
fs.writeFileSync(plistPath, plist);
|
|
112
|
+
launchctl(`bootout gui/${uid}/${LABEL}`);
|
|
113
|
+
launchctl(`bootstrap gui/${uid} ${plistPath}`);
|
|
114
|
+
// launchd reports the job async; give it a moment to reach running state.
|
|
115
|
+
let out = '';
|
|
116
|
+
for (let i = 0; i < 5 && !out.includes('state = running'); i++) {
|
|
117
|
+
execSync('sleep 1');
|
|
118
|
+
out = launchctl(`print gui/${uid}/${LABEL}`);
|
|
119
|
+
}
|
|
120
|
+
if (out.includes('state = running')) {
|
|
121
|
+
console.log('\n Ground Control daemon is now running in the background.');
|
|
122
|
+
console.log(' It starts at login and restarts automatically if it stops.\n');
|
|
123
|
+
console.log(` Log: ${logFile}`);
|
|
124
|
+
console.log(' Pair: groundcontrol qr');
|
|
125
|
+
console.log(' Check: groundcontrol service status');
|
|
126
|
+
console.log(' Remove: groundcontrol service uninstall\n');
|
|
127
|
+
} else {
|
|
128
|
+
console.log('\n Install finished but the service is not reporting as running.');
|
|
129
|
+
console.log(` Check ${logFile} and try: launchctl bootstrap gui/${uid} ${plistPath}\n`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (action === 'uninstall') {
|
|
136
|
+
launchctl(`bootout gui/${uid}/${LABEL}`);
|
|
137
|
+
try { fs.unlinkSync(plistPath); } catch {}
|
|
138
|
+
console.log('\n Background service removed. Run the daemon manually with: groundcontrol\n');
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (action === 'status') {
|
|
143
|
+
const out = launchctl(`print gui/${uid}/${LABEL}`);
|
|
144
|
+
if (out.includes('state = running')) {
|
|
145
|
+
console.log('\n Running in the background.');
|
|
146
|
+
} else if (fs.existsSync(plistPath)) {
|
|
147
|
+
console.log('\n Installed but not running. Reinstall with: groundcontrol service install');
|
|
148
|
+
} else {
|
|
149
|
+
console.log('\n Not installed. Set it up with: groundcontrol service install');
|
|
150
|
+
}
|
|
151
|
+
console.log(` Log: ${logFile}\n`);
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log('\n Usage: groundcontrol service <install|uninstall|status>\n');
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
48
159
|
const argRelay = (() => {
|
|
49
160
|
const i = process.argv.indexOf('--relay');
|
|
50
161
|
return i > -1 ? process.argv[i + 1] : null;
|
|
@@ -84,6 +195,11 @@ console.log(` channel: ${CHANNEL}\n`);
|
|
|
84
195
|
qrcode.generate(pairString, { small: true });
|
|
85
196
|
console.log(`\n Pairing code (paste into the app):\n\n ${pairString}\n`);
|
|
86
197
|
|
|
198
|
+
if (subcommand === 'qr') process.exit(0);
|
|
199
|
+
|
|
200
|
+
console.log(' Tip: keep this running without a terminal:');
|
|
201
|
+
console.log(' npx @clastres/groundcontrol service install\n');
|
|
202
|
+
|
|
87
203
|
// ---------- crypto: wire format = base64( nonce(12) | ciphertext | tag(16) ) ----------
|
|
88
204
|
|
|
89
205
|
function seal(obj) {
|
|
@@ -414,7 +530,14 @@ function connect() {
|
|
|
414
530
|
if (str.startsWith('{"sys"')) return;
|
|
415
531
|
try { handleCommand(open(str))?.catch?.(() => {}); } catch (e) { console.log('[daemon] bad message (wrong key?)'); }
|
|
416
532
|
});
|
|
417
|
-
ws.on('close', () => {
|
|
533
|
+
ws.on('close', (code) => {
|
|
534
|
+
if (code === 4001) {
|
|
535
|
+
console.log('[daemon] another daemon took over this channel, exiting. Run only one copy.');
|
|
536
|
+
process.exit(0);
|
|
537
|
+
}
|
|
538
|
+
console.log('[daemon] relay disconnected, retrying in 3s');
|
|
539
|
+
setTimeout(connect, 3000);
|
|
540
|
+
});
|
|
418
541
|
ws.on('error', () => {});
|
|
419
542
|
}
|
|
420
543
|
|
package/package.json
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
-
"bin": {
|
|
5
|
+
"bin": {
|
|
6
|
+
"groundcontrol": "./bin/groundcontrol.mjs"
|
|
7
|
+
},
|
|
6
8
|
"type": "module",
|
|
7
|
-
"engines": {
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=20"
|
|
11
|
+
},
|
|
8
12
|
"dependencies": {
|
|
9
13
|
"qrcode-terminal": "^0.12.0",
|
|
10
14
|
"ws": "^8.18.0"
|
|
11
15
|
},
|
|
12
|
-
"keywords": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"claude",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"ai",
|
|
20
|
+
"agents",
|
|
21
|
+
"cli",
|
|
22
|
+
"ios",
|
|
23
|
+
"remote",
|
|
24
|
+
"monitor"
|
|
25
|
+
],
|
|
13
26
|
"homepage": "https://groundcontrol.chat",
|
|
14
27
|
"author": "Carlos Lastres",
|
|
15
28
|
"license": "MIT"
|
|
16
|
-
}
|
|
29
|
+
}
|