@clastres/groundcontrol 0.1.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ # Ground Control
2
+
3
+ Your AI agents, in your pocket. Every Claude Code session on this Mac becomes a pixel pilot in the [Ground Control iPhone app](https://groundcontrol.chat): watch them work, read replies as they stream, send new orders from anywhere.
4
+
5
+ ## Setup
6
+
7
+ ```sh
8
+ npx groundcontrol
9
+ ```
10
+
11
+ Scan the QR code it prints with the Ground Control app. Done. No account.
12
+
13
+ ## How it stays private
14
+
15
+ The pairing key is generated on this machine and shared only through that QR code. Everything travels end to end encrypted (ChaCha20 Poly1305); the relay routes sealed bytes it cannot read, and stores nothing.
16
+
17
+ ## Requirements
18
+
19
+ A Mac running [Claude Code](https://claude.com/claude-code), Node 20+, and the Ground Control iPhone app.
20
+
21
+ Docs and FAQ: https://groundcontrol.chat
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../daemon.mjs';
package/daemon.mjs ADDED
@@ -0,0 +1,435 @@
1
+ // Ground Control daemon — runs on the Mac next to Claude Code.
2
+ // Watches live sessions, encrypts everything with ChaCha20-Poly1305, and
3
+ // publishes through the relay. The relay never sees plaintext.
4
+ //
5
+ // node server/daemon.mjs [--relay ws://localhost:8787]
6
+ //
7
+ // First run generates a pairing key and prints a QR code + pairing string
8
+ // for the iOS app. Key lives in ~/.groundcontrol/key.
9
+
10
+ import { WebSocket } from 'ws';
11
+ import qrcode from 'qrcode-terminal';
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { spawn, execFile, execSync } from 'node:child_process';
17
+ import { promisify } from 'node:util';
18
+
19
+ const execFileP = promisify(execFile);
20
+
21
+ // ---------- config / pairing ----------
22
+
23
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
24
+ console.log(`
25
+ GROUND CONTROL daemon
26
+
27
+ Usage: groundcontrol [options]
28
+
29
+ Shows the Claude Code sessions on this Mac in the Ground Control iPhone app.
30
+ On first run it generates a pairing key and prints a QR code. Scan it with
31
+ the app and you are connected. No account needed.
32
+
33
+ Options:
34
+ --relay <url> Use a custom relay (default: wss://relay.groundcontrol.chat)
35
+ --version Print version
36
+ --help This message
37
+
38
+ Everything is end to end encrypted with a key that never leaves your machines.
39
+ Docs: https://groundcontrol.chat
40
+ `);
41
+ process.exit(0);
42
+ }
43
+ if (process.argv.includes('--version')) {
44
+ console.log('0.1.0');
45
+ process.exit(0);
46
+ }
47
+
48
+ const argRelay = (() => {
49
+ const i = process.argv.indexOf('--relay');
50
+ return i > -1 ? process.argv[i + 1] : null;
51
+ })();
52
+
53
+ const CONF_DIR = path.join(os.homedir(), '.groundcontrol');
54
+ const KEY_FILE = path.join(CONF_DIR, 'key');
55
+ const RELAY_FILE = path.join(CONF_DIR, 'relay');
56
+ fs.mkdirSync(CONF_DIR, { recursive: true });
57
+
58
+ if (argRelay) fs.writeFileSync(RELAY_FILE, argRelay);
59
+ const RELAY = fs.existsSync(RELAY_FILE) ? fs.readFileSync(RELAY_FILE, 'utf8').trim() : 'wss://relay.groundcontrol.chat';
60
+
61
+ let key;
62
+ if (fs.existsSync(KEY_FILE)) {
63
+ key = Buffer.from(fs.readFileSync(KEY_FILE, 'utf8').trim(), 'hex');
64
+ } else {
65
+ key = crypto.randomBytes(32);
66
+ fs.writeFileSync(KEY_FILE, key.toString('hex'), { mode: 0o600 });
67
+ }
68
+ const CHANNEL = crypto.createHash('sha256').update(key).digest('hex').slice(0, 32);
69
+
70
+ // LAN address so a phone on the same network can reach a locally hosted relay.
71
+ const lanIP = Object.values(os.networkInterfaces()).flat()
72
+ .find((i) => i && i.family === 'IPv4' && !i.internal)?.address;
73
+ const relayForPhone = RELAY.includes('localhost') && lanIP
74
+ ? RELAY.replace('localhost', lanIP)
75
+ : RELAY;
76
+
77
+ const pairPayload = Buffer.from(JSON.stringify({ v: 1, k: key.toString('base64url'), r: relayForPhone }))
78
+ .toString('base64url');
79
+ const pairString = `groundcontrol://pair#${pairPayload}`;
80
+
81
+ console.log('\n GROUND CONTROL daemon');
82
+ console.log(` relay: ${RELAY} (phone will use: ${relayForPhone})`);
83
+ console.log(` channel: ${CHANNEL}\n`);
84
+ qrcode.generate(pairString, { small: true });
85
+ console.log(`\n Pairing code (paste into the app):\n\n ${pairString}\n`);
86
+
87
+ // ---------- crypto: wire format = base64( nonce(12) | ciphertext | tag(16) ) ----------
88
+
89
+ function seal(obj) {
90
+ const nonce = crypto.randomBytes(12);
91
+ const cipher = crypto.createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
92
+ const ct = Buffer.concat([cipher.update(JSON.stringify(obj), 'utf8'), cipher.final()]);
93
+ return Buffer.concat([nonce, ct, cipher.getAuthTag()]).toString('base64');
94
+ }
95
+
96
+ function open(b64) {
97
+ const buf = Buffer.from(b64, 'base64');
98
+ const nonce = buf.subarray(0, 12);
99
+ const tag = buf.subarray(buf.length - 16);
100
+ const ct = buf.subarray(12, buf.length - 16);
101
+ const decipher = crypto.createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
102
+ decipher.setAuthTag(tag);
103
+ const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
104
+ return JSON.parse(pt.toString('utf8'));
105
+ }
106
+
107
+ // ---------- Claude Code session discovery ----------
108
+
109
+ const CLAUDE = path.join(os.homedir(), '.claude');
110
+ const SESSIONS_DIR = path.join(CLAUDE, 'sessions');
111
+ const PROJECTS_DIR = path.join(CLAUDE, 'projects');
112
+
113
+ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
114
+
115
+ // ---------- terminal injection (phase 2) ----------
116
+ // Types the prompt into the actual terminal tab hosting the session, so the
117
+ // desktop conversation updates live. Text and Enter are sent as separate
118
+ // chunks: Claude Code treats a same-chunk trailing newline as a paste, but a
119
+ // separate newline as a submit keypress.
120
+
121
+ const ttyByPid = new Map();
122
+ function ttyOf(pid) {
123
+ if (ttyByPid.has(pid)) return ttyByPid.get(pid);
124
+ let tty = '';
125
+ try { tty = execSync(`ps -o tty= -p ${pid}`).toString().trim(); } catch {}
126
+ if (tty === '??') tty = '';
127
+ ttyByPid.set(pid, tty);
128
+ return tty;
129
+ }
130
+
131
+ let termCache = { at: 0, terminal: false, iterm: false, tmuxPanes: new Map() };
132
+ function terminalApps() {
133
+ if (Date.now() - termCache.at < 15_000) return termCache;
134
+ const running = (name) => { try { return execSync(`pgrep -x "${name}"`).toString().trim() !== ''; } catch { return false; } };
135
+ const tmuxPanes = new Map();
136
+ try {
137
+ for (const line of execSync('tmux list-panes -a -F "#{pane_tty} #{pane_id}"', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim().split('\n')) {
138
+ const [tty, id] = line.split(' ');
139
+ if (tty && id) tmuxPanes.set(tty, id);
140
+ }
141
+ } catch {}
142
+ termCache = { at: Date.now(), terminal: running('Terminal'), iterm: running('iTerm2'), tmuxPanes };
143
+ return termCache;
144
+ }
145
+
146
+ const escapeAS = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
147
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
148
+
149
+ async function osascript(script) {
150
+ try {
151
+ const { stdout } = await execFileP('osascript', ['-e', script]);
152
+ return stdout.trim();
153
+ } catch { return null; }
154
+ }
155
+
156
+ function findTabScript(app, dev, action) {
157
+ return `tell application "${app}"
158
+ repeat with w in windows
159
+ repeat with tb in tabs of w
160
+ if (tty of tb) is "${dev}" then
161
+ ${action}
162
+ return "ok"
163
+ end if
164
+ end repeat
165
+ end repeat
166
+ return "notfound"
167
+ end tell`;
168
+ }
169
+
170
+ function canInject(tty) {
171
+ if (!tty) return false;
172
+ const apps = terminalApps();
173
+ return apps.tmuxPanes.has(`/dev/${tty}`) || apps.terminal || apps.iterm;
174
+ }
175
+
176
+ // Returns the method used, or null if no terminal path was found.
177
+ async function injectPrompt(tty, text) {
178
+ const dev = `/dev/${tty}`;
179
+ const apps = terminalApps();
180
+ const clean = text.replace(/\r?\n/g, ' ').trim();
181
+
182
+ if (apps.tmuxPanes.has(dev)) {
183
+ try {
184
+ execSync(`tmux send-keys -t '${apps.tmuxPanes.get(dev)}' -l ${JSON.stringify(clean)}`);
185
+ await sleep(150);
186
+ execSync(`tmux send-keys -t '${apps.tmuxPanes.get(dev)}' Enter`);
187
+ return 'tmux';
188
+ } catch {}
189
+ }
190
+ if (apps.iterm) {
191
+ const r = await osascript(`tell application "iTerm2"
192
+ repeat with w in windows
193
+ repeat with tb in tabs of w
194
+ repeat with s in sessions of tb
195
+ if (tty of s) is "${dev}" then
196
+ tell s to write text "${escapeAS(clean)}" newline no
197
+ return "ok"
198
+ end if
199
+ end repeat
200
+ end repeat
201
+ end repeat
202
+ return "notfound"
203
+ end tell`);
204
+ if (r === 'ok') {
205
+ await sleep(150);
206
+ await osascript(`tell application "iTerm2"
207
+ repeat with w in windows
208
+ repeat with tb in tabs of w
209
+ repeat with s in sessions of tb
210
+ if (tty of s) is "${dev}" then
211
+ tell s to write text ""
212
+ return "ok"
213
+ end if
214
+ end repeat
215
+ end repeat
216
+ end repeat
217
+ end tell`);
218
+ return 'iterm';
219
+ }
220
+ }
221
+ if (apps.terminal) {
222
+ const r = await osascript(findTabScript('Terminal', dev, `do script "${escapeAS(clean)}" in tb`));
223
+ if (r === 'ok') {
224
+ await sleep(200);
225
+ await osascript(findTabScript('Terminal', dev, 'do script "" in tb'));
226
+ return 'terminal';
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+
232
+ const headlessActive = new Set(); // sessionIds with a headless --resume in flight
233
+
234
+ const transcriptCache = new Map(); // sessionId -> file path
235
+
236
+ function findTranscript(sessionId) {
237
+ if (transcriptCache.has(sessionId)) {
238
+ const p = transcriptCache.get(sessionId);
239
+ if (p && fs.existsSync(p)) return p;
240
+ }
241
+ try {
242
+ for (const dir of fs.readdirSync(PROJECTS_DIR)) {
243
+ const p = path.join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
244
+ if (fs.existsSync(p)) { transcriptCache.set(sessionId, p); return p; }
245
+ }
246
+ } catch {}
247
+ transcriptCache.set(sessionId, null);
248
+ return null;
249
+ }
250
+
251
+ function extractText(message) {
252
+ const c = message?.content;
253
+ if (typeof c === 'string') return c;
254
+ if (Array.isArray(c)) {
255
+ return c.filter((b) => b.type === 'text').map((b) => b.text).join('\n');
256
+ }
257
+ return '';
258
+ }
259
+
260
+ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
261
+ const file = findTranscript(sessionId);
262
+ if (!file) return [];
263
+ let raw;
264
+ try {
265
+ const size = fs.statSync(file).size;
266
+ const fd = fs.openSync(file, 'r');
267
+ const start = Math.max(0, size - maxBytes);
268
+ const buf = Buffer.alloc(size - start);
269
+ fs.readSync(fd, buf, 0, buf.length, start);
270
+ fs.closeSync(fd);
271
+ raw = buf.toString('utf8');
272
+ } catch { return []; }
273
+
274
+ const out = [];
275
+ for (const line of raw.split('\n')) {
276
+ if (!line.trim()) continue;
277
+ let e; try { e = JSON.parse(line); } catch { continue; }
278
+ if (e.isSidechain) continue;
279
+ const role = e.message?.role;
280
+ if (role !== 'user' && role !== 'assistant') continue;
281
+ const text = extractText(e.message).trim();
282
+ if (!text || /^(<system-reminder>|<command-name>|<local-command|<command-message|\[Request interrupted|\[SYSTEM NOTIFICATION)/.test(text)) continue;
283
+ out.push({ role, text: text.slice(0, 4000), ts: e.timestamp || null });
284
+ }
285
+ return out.slice(-limit);
286
+ }
287
+
288
+ function readSessions() {
289
+ const sessions = [];
290
+ let files = [];
291
+ try { files = fs.readdirSync(SESSIONS_DIR); } catch {}
292
+ for (const f of files) {
293
+ if (!f.endsWith('.json')) continue;
294
+ let s;
295
+ try { s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')); } catch { continue; }
296
+ if (!s.sessionId || !pidAlive(s.pid)) continue;
297
+ const msgs = readMessages(s.sessionId, 64 * 1024, 1);
298
+ const tty = ttyOf(s.pid);
299
+ sessions.push({
300
+ id: s.sessionId,
301
+ name: s.name || path.basename(s.cwd || '?'),
302
+ cwd: s.cwd || '',
303
+ project: path.basename(s.cwd || '?'),
304
+ status: headlessActive.has(s.sessionId) ? 'busy' : (s.status || 'idle'),
305
+ updatedAt: s.statusUpdatedAt || s.updatedAt || 0,
306
+ startedAt: s.startedAt || 0,
307
+ lastMessage: msgs.length ? msgs[msgs.length - 1] : null,
308
+ tty,
309
+ live: canInject(tty),
310
+ });
311
+ }
312
+ sessions.sort((a, b) => a.startedAt - b.startedAt);
313
+ return sessions;
314
+ }
315
+
316
+ // ---------- relay connection ----------
317
+
318
+ let ws = null;
319
+ let lastSnapshotJson = '';
320
+
321
+ function sendSnapshot(force = false) {
322
+ const sessions = readSessions();
323
+ const json = JSON.stringify(sessions.map(({ lastMessage, ...s }) => ({ ...s, last: lastMessage?.text?.slice(0, 120) })));
324
+ if (!force && json === lastSnapshotJson) return;
325
+ lastSnapshotJson = json;
326
+ if (ws?.readyState === 1) {
327
+ ws.send(seal({ type: 'snapshot', at: Date.now(), sessions }));
328
+ console.log(`[daemon] snapshot -> ${sessions.length} session(s): ${sessions.map((s) => `${s.name}:${s.status}`).join(', ') || 'none'}`);
329
+ }
330
+ }
331
+
332
+ // Live transcript streaming: while a phone has a chat open, push transcript
333
+ // updates the moment the JSONL file changes.
334
+ const watchers = new Map(); // sessionId -> fs.FSWatcher
335
+ function watchTranscript(sessionId) {
336
+ if (watchers.has(sessionId)) return;
337
+ const file = findTranscript(sessionId);
338
+ if (!file) return;
339
+ let debounce;
340
+ try {
341
+ const w = fs.watch(file, () => {
342
+ clearTimeout(debounce);
343
+ debounce = setTimeout(() => {
344
+ ws?.send(seal({ type: 'transcript', sessionId, messages: readMessages(sessionId) }));
345
+ }, 250);
346
+ });
347
+ watchers.set(sessionId, w);
348
+ console.log(`[daemon] watching transcript ${sessionId.slice(0, 8)}`);
349
+ } catch {}
350
+ }
351
+ function unwatchTranscript(sessionId) {
352
+ watchers.get(sessionId)?.close();
353
+ watchers.delete(sessionId);
354
+ }
355
+
356
+ async function handleCommand(cmd) {
357
+ if (cmd.type === 'getTranscript' && cmd.sessionId) {
358
+ ws?.send(seal({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) }));
359
+ } else if (cmd.type === 'watch' && cmd.sessionId) {
360
+ watchTranscript(cmd.sessionId);
361
+ ws?.send(seal({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) }));
362
+ } else if (cmd.type === 'unwatch' && cmd.sessionId) {
363
+ unwatchTranscript(cmd.sessionId);
364
+ } else if (cmd.type === 'ping' && cmd.sessionId) {
365
+ const s = readSessions().find((x) => x.id === cmd.sessionId);
366
+ ws?.send(seal({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() }));
367
+ } else if (cmd.type === 'prompt' && cmd.sessionId && cmd.text) {
368
+ const session = readSessions().find((s) => s.id === cmd.sessionId);
369
+ if (!session) return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' }));
370
+
371
+ // Preferred path: type into the real terminal so the desktop updates live.
372
+ if (session.live) {
373
+ const method = await injectPrompt(session.tty, cmd.text);
374
+ if (method) {
375
+ console.log(`[daemon] injected prompt into ${session.name} via ${method}`);
376
+ ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: true, text: '' }));
377
+ watchTranscript(cmd.sessionId);
378
+ sendSnapshot(true);
379
+ return;
380
+ }
381
+ }
382
+ 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' }));
383
+ console.log(`[daemon] prompt -> ${session.name}: ${cmd.text.slice(0, 60)}`);
384
+ headlessActive.add(cmd.sessionId);
385
+ sendSnapshot(true);
386
+ const child = spawn('claude', ['--resume', cmd.sessionId, '-p', cmd.text], {
387
+ cwd: session.cwd || os.homedir(),
388
+ env: process.env,
389
+ stdio: ['ignore', 'pipe', 'pipe'],
390
+ });
391
+ let out = '', err = '';
392
+ child.stdout.on('data', (d) => { out += d; });
393
+ child.stderr.on('data', (d) => { err += d; });
394
+ child.on('close', (code) => {
395
+ headlessActive.delete(cmd.sessionId);
396
+ ws?.send(seal({
397
+ type: 'promptResult', sessionId: cmd.sessionId, ok: code === 0,
398
+ text: (out || err).trim().slice(0, 4000),
399
+ }));
400
+ sendSnapshot(true);
401
+ });
402
+ }
403
+ }
404
+
405
+ let lastPongAt = Date.now();
406
+
407
+ function connect() {
408
+ ws = new WebSocket(`${RELAY}/ch/${CHANNEL}?role=host`);
409
+ ws.on('open', () => { lastPongAt = Date.now(); console.log('[daemon] connected to relay'); sendSnapshot(true); });
410
+ ws.on('pong', () => { lastPongAt = Date.now(); });
411
+ ws.on('message', (data) => {
412
+ const str = data.toString();
413
+ if (str === '{"sys":"client-joined"}') { console.log('[daemon] phone connected'); sendSnapshot(true); return; }
414
+ if (str.startsWith('{"sys"')) return;
415
+ try { handleCommand(open(str))?.catch?.(() => {}); } catch (e) { console.log('[daemon] bad message (wrong key?)'); }
416
+ });
417
+ ws.on('close', () => { console.log('[daemon] relay disconnected, retrying in 3s'); setTimeout(connect, 3000); });
418
+ ws.on('error', () => {});
419
+ }
420
+
421
+ connect();
422
+ setInterval(sendSnapshot, 1500);
423
+ // Heartbeat traffic keeps the Cloudflare socket alive in both directions and
424
+ // lets the phone detect a dead link; missing pongs force a reconnect here.
425
+ setInterval(() => {
426
+ if (ws?.readyState === 1) {
427
+ ws.ping();
428
+ ws.send(seal({ type: 'hb', at: Date.now() }));
429
+ if (Date.now() - lastPongAt > 65_000) {
430
+ console.log('[daemon] stale relay link, forcing reconnect');
431
+ ws.terminate();
432
+ }
433
+ }
434
+ }, 20_000);
435
+ try { fs.watch(SESSIONS_DIR, () => sendSnapshot()); } catch {}
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@clastres/groundcontrol",
3
+ "version": "0.1.0",
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": { "groundcontrol": "./bin/groundcontrol.mjs" },
6
+ "type": "module",
7
+ "engines": { "node": ">=20" },
8
+ "dependencies": {
9
+ "qrcode-terminal": "^0.12.0",
10
+ "ws": "^8.18.0"
11
+ },
12
+ "keywords": ["claude", "claude-code", "ai", "agents", "cli", "ios", "remote", "monitor"],
13
+ "homepage": "https://groundcontrol.chat",
14
+ "author": "Carlos Lastres",
15
+ "license": "MIT"
16
+ }