@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 +21 -0
- package/bin/groundcontrol.mjs +2 -0
- package/daemon.mjs +435 -0
- package/package.json +16 -0
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
|
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
|
+
}
|