@clastres/groundcontrol 0.1.9 → 0.1.10
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 +173 -1
- package/package.json +1 -1
- package/test/launch.mjs +73 -0
package/daemon.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { createRequire } from 'node:module';
|
|
|
22
22
|
|
|
23
23
|
const execFileP = promisify(execFile);
|
|
24
24
|
|
|
25
|
-
const VERSION = '0.1.
|
|
25
|
+
const VERSION = '0.1.10';
|
|
26
26
|
const PACKAGE = '@clastres/groundcontrol';
|
|
27
27
|
|
|
28
28
|
// ---------- config / pairing ----------
|
|
@@ -1673,6 +1673,10 @@ function hostState() {
|
|
|
1673
1673
|
settingsOk: settings !== null,
|
|
1674
1674
|
socketOk: approvalServerUp,
|
|
1675
1675
|
},
|
|
1676
|
+
// Which agents the phone may start here. An empty list is a real answer
|
|
1677
|
+
// (nothing launchable found); the whole field missing is an older daemon,
|
|
1678
|
+
// and the app hides the button rather than offering one that cannot work.
|
|
1679
|
+
launch: { providers: launchProviders() },
|
|
1676
1680
|
updateAvailable: availableUpdate,
|
|
1677
1681
|
};
|
|
1678
1682
|
hostCache = { at: Date.now(), value };
|
|
@@ -2106,6 +2110,14 @@ async function handleCommand(cmd) {
|
|
|
2106
2110
|
settleAsk(ask, { behavior: 'deny', message: reason || 'Denied from the Ground Control app.' },
|
|
2107
2111
|
'denied', reason ? `from the phone: ${reason.slice(0, 60)}` : 'from the phone');
|
|
2108
2112
|
}
|
|
2113
|
+
} else if (cmd.type === 'projects') {
|
|
2114
|
+
ws?.send(seal({
|
|
2115
|
+
type: 'projects',
|
|
2116
|
+
projects: knownProjects().map((p) => ({ path: p.path, name: p.name, at: p.at })),
|
|
2117
|
+
providers: launchProviders(),
|
|
2118
|
+
}));
|
|
2119
|
+
} else if (cmd.type === 'launch') {
|
|
2120
|
+
await launchSession(cmd);
|
|
2109
2121
|
} else if (cmd.type === 'ping' && cmd.sessionId) {
|
|
2110
2122
|
const s = readSessions().find((x) => x.id === cmd.sessionId);
|
|
2111
2123
|
ws?.send(seal({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() }));
|
|
@@ -2258,6 +2270,166 @@ function promptHeadless(session, text) {
|
|
|
2258
2270
|
watchTranscript(session.id);
|
|
2259
2271
|
}
|
|
2260
2272
|
|
|
2273
|
+
// ---------- launching a new pilot ----------
|
|
2274
|
+
// Everything above answers a session that already exists. This starts one.
|
|
2275
|
+
//
|
|
2276
|
+
// The phone never sends a path. It picks from a list this daemon built out of
|
|
2277
|
+
// what is already on disk, and the launch validates the choice against that
|
|
2278
|
+
// same list before running anything, so the worst a stolen channel can do is
|
|
2279
|
+
// open a terminal in a folder you have already worked in today.
|
|
2280
|
+
|
|
2281
|
+
const PROJECT_LIMIT = 40;
|
|
2282
|
+
/// A folder nobody has touched in three months is not what you meant to open
|
|
2283
|
+
/// from your phone, and the list has to fit on a phone screen.
|
|
2284
|
+
const PROJECT_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
|
|
2285
|
+
|
|
2286
|
+
/// The real working directory behind one of Claude Code's project folders.
|
|
2287
|
+
///
|
|
2288
|
+
/// The folder name is the path with every slash turned into a dash, which
|
|
2289
|
+
/// cannot be undone: a directory with a dash in its own name decodes to
|
|
2290
|
+
/// something that was never there. The transcripts inside record the actual
|
|
2291
|
+
/// cwd, so read it rather than guess it.
|
|
2292
|
+
function projectCwd(dir) {
|
|
2293
|
+
let files = [];
|
|
2294
|
+
try {
|
|
2295
|
+
files = fs.readdirSync(dir)
|
|
2296
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
2297
|
+
.map((f) => {
|
|
2298
|
+
const p = path.join(dir, f);
|
|
2299
|
+
try { return { p, at: fs.statSync(p).mtimeMs }; } catch { return null; }
|
|
2300
|
+
})
|
|
2301
|
+
.filter(Boolean)
|
|
2302
|
+
.sort((a, b) => b.at - a.at);
|
|
2303
|
+
} catch { return null; }
|
|
2304
|
+
if (!files.length) return null;
|
|
2305
|
+
|
|
2306
|
+
for (const { p, at } of files.slice(0, 3)) {
|
|
2307
|
+
let head = '';
|
|
2308
|
+
try {
|
|
2309
|
+
const fd = fs.openSync(p, 'r');
|
|
2310
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
2311
|
+
const read = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
2312
|
+
fs.closeSync(fd);
|
|
2313
|
+
head = buf.subarray(0, read).toString('utf8');
|
|
2314
|
+
} catch { continue; }
|
|
2315
|
+
for (const line of head.split('\n')) {
|
|
2316
|
+
if (!line.trim()) continue;
|
|
2317
|
+
let e; try { e = JSON.parse(line); } catch { continue; }
|
|
2318
|
+
if (typeof e.cwd === 'string' && e.cwd) return { cwd: e.cwd, at };
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
return null;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
let projectsCache = { at: 0, value: [] };
|
|
2325
|
+
function knownProjects() {
|
|
2326
|
+
if (Date.now() - projectsCache.at < 60_000) return projectsCache.value;
|
|
2327
|
+
const byPath = new Map();
|
|
2328
|
+
const note = (cwd, at) => {
|
|
2329
|
+
if (!cwd || !fs.existsSync(cwd)) return;
|
|
2330
|
+
const prev = byPath.get(cwd);
|
|
2331
|
+
if (!prev || prev.at < at) byPath.set(cwd, { path: cwd, name: path.basename(cwd) || cwd, at });
|
|
2332
|
+
};
|
|
2333
|
+
|
|
2334
|
+
let dirs = [];
|
|
2335
|
+
try { dirs = fs.readdirSync(PROJECTS_DIR); } catch {}
|
|
2336
|
+
for (const d of dirs) {
|
|
2337
|
+
const found = projectCwd(path.join(PROJECTS_DIR, d));
|
|
2338
|
+
if (found) note(found.cwd, found.at);
|
|
2339
|
+
}
|
|
2340
|
+
// Whatever is flying right now, even if its transcript has not landed yet.
|
|
2341
|
+
for (const s of readSessions()) note(s.cwd, Date.now());
|
|
2342
|
+
|
|
2343
|
+
const cutoff = Date.now() - PROJECT_MAX_AGE_MS;
|
|
2344
|
+
const value = [...byPath.values()]
|
|
2345
|
+
.filter((p) => p.at >= cutoff)
|
|
2346
|
+
.sort((a, b) => b.at - a.at)
|
|
2347
|
+
.slice(0, PROJECT_LIMIT);
|
|
2348
|
+
projectsCache = { at: Date.now(), value };
|
|
2349
|
+
return value;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/// Which agents this Mac can start from the phone.
|
|
2353
|
+
///
|
|
2354
|
+
/// Claude Code only, on purpose. Every provider here has to be one whose bare
|
|
2355
|
+
/// command opens an interactive session that then shows up on the island, and
|
|
2356
|
+
/// claude is the one that has been checked. Codex and the kimi CLIs are read
|
|
2357
|
+
/// and written by the daemon already, but nobody has watched either of them
|
|
2358
|
+
/// come up this way, so offering them would be a button that might do nothing.
|
|
2359
|
+
function launchProviders() {
|
|
2360
|
+
return claudeBin() ? ['claude'] : [];
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/// Single quotes for the shell, then AppleScript quoting on top. Both layers
|
|
2364
|
+
/// matter: the directory comes from disk and the first order comes from your
|
|
2365
|
+
/// thumbs.
|
|
2366
|
+
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
2367
|
+
|
|
2368
|
+
let lastLaunchAt = 0;
|
|
2369
|
+
|
|
2370
|
+
async function launchSession(cmd) {
|
|
2371
|
+
const fail = (error) => ws?.send(seal({ type: 'launchResult', ok: false, error }));
|
|
2372
|
+
|
|
2373
|
+
const providers = launchProviders();
|
|
2374
|
+
const provider = cmd.provider || 'claude';
|
|
2375
|
+
if (!providers.includes(provider)) {
|
|
2376
|
+
return fail(`This Mac cannot start a ${provider} session from the phone. Ground Control could not find the ${provider} command on your PATH.`);
|
|
2377
|
+
}
|
|
2378
|
+
const target = knownProjects().find((p) => p.path === cmd.cwd);
|
|
2379
|
+
if (!target) {
|
|
2380
|
+
// The list is a minute old at most, so this is almost always a folder that
|
|
2381
|
+
// was renamed or deleted since it was drawn.
|
|
2382
|
+
return fail('That folder is not on this Mac any more. Pull the list again.');
|
|
2383
|
+
}
|
|
2384
|
+
if (Date.now() - lastLaunchAt < 5000) {
|
|
2385
|
+
return fail('One at a time. Give the last launch a few seconds.');
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
const apps = terminalApps();
|
|
2389
|
+
const first = (cmd.text || '').trim();
|
|
2390
|
+
// Claude Code takes an opening prompt as a positional argument and starts the
|
|
2391
|
+
// normal interactive session with it already submitted, which beats waiting a
|
|
2392
|
+
// guessed number of seconds for a REPL to boot and then typing at it.
|
|
2393
|
+
const line = `cd ${shq(target.path)} && ${provider}${first ? ` ${shq(first)}` : ''}`;
|
|
2394
|
+
|
|
2395
|
+
lastLaunchAt = Date.now();
|
|
2396
|
+
let opened = null;
|
|
2397
|
+
if (apps.iterm) {
|
|
2398
|
+
const r = await osascript(`tell application "iTerm2"
|
|
2399
|
+
activate
|
|
2400
|
+
set w to (create window with default profile)
|
|
2401
|
+
tell current session of w to write text "${escapeAS(line)}"
|
|
2402
|
+
return "ok"
|
|
2403
|
+
end tell`);
|
|
2404
|
+
if (r === 'ok') opened = 'iTerm2';
|
|
2405
|
+
}
|
|
2406
|
+
if (!opened) {
|
|
2407
|
+
// Terminal.app is on every Mac, and telling it to do a script launches it
|
|
2408
|
+
// if it was closed, so this is the path that always exists.
|
|
2409
|
+
const r = await osascript(`tell application "Terminal"
|
|
2410
|
+
activate
|
|
2411
|
+
do script "${escapeAS(line)}"
|
|
2412
|
+
return "ok"
|
|
2413
|
+
end tell`);
|
|
2414
|
+
if (r === 'ok') opened = 'Terminal';
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
if (!opened) {
|
|
2418
|
+
lastLaunchAt = 0;
|
|
2419
|
+
if (automationDenied) {
|
|
2420
|
+
return fail('Your Mac is blocking terminal control. Open System Settings, Privacy and Security, Automation, and allow node to control your terminal app. Then try again.');
|
|
2421
|
+
}
|
|
2422
|
+
return fail('Could not open a terminal window on your Mac.');
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
console.log(`[daemon] launched ${provider} in ${target.path} via ${opened}`);
|
|
2426
|
+
// The new session reaches the island through the same discovery every other
|
|
2427
|
+
// one does, which takes a few seconds. Say so rather than implying the pilot
|
|
2428
|
+
// is already there.
|
|
2429
|
+
ws?.send(seal({ type: 'launchResult', ok: true, cwd: target.path, note: `${provider} is starting in ${target.name}` }));
|
|
2430
|
+
sendSnapshot(true);
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2261
2433
|
let lastPongAt = Date.now();
|
|
2262
2434
|
|
|
2263
2435
|
function connect() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "See and command your coding agents from your iPhone. Pairs the Ground Control app with Claude Code, Codex, Kimi and Grok Bot sessions running on this Mac, end to end encrypted.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"groundcontrol": "bin/groundcontrol.mjs"
|
package/test/launch.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Starting a session from the phone crosses the two languages twice: the app
|
|
2
|
+
// sends a command the daemon has to know, and the daemon answers with a reply
|
|
3
|
+
// type the app has to decode. Either half can be renamed on its own and
|
|
4
|
+
// nothing complains until a button on a phone quietly does nothing.
|
|
5
|
+
//
|
|
6
|
+
// Static checks on both sources, in the same spirit as providers.mjs. They do
|
|
7
|
+
// not prove a terminal opens; they prove the two ends are still talking about
|
|
8
|
+
// the same thing.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const daemon = fs.readFileSync(path.join(here, '..', 'daemon.mjs'), 'utf8');
|
|
15
|
+
const app = path.join(here, '..', '..', 'ios', 'GroundControl');
|
|
16
|
+
const client = fs.readFileSync(path.join(app, 'RelayClient.swift'), 'utf8');
|
|
17
|
+
const models = fs.readFileSync(path.join(app, 'Models.swift'), 'utf8');
|
|
18
|
+
|
|
19
|
+
let failures = 0;
|
|
20
|
+
const check = (label, cond, detail = '') => {
|
|
21
|
+
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
22
|
+
if (!cond) failures++;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ---------- every command the app sends, the daemon handles ----------
|
|
26
|
+
|
|
27
|
+
const sent = [...client.matchAll(/Command\(type:\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
28
|
+
check('the app sends commands at all', sent.length >= 8, `${sent.length} found`);
|
|
29
|
+
for (const type of [...new Set(sent)].sort()) {
|
|
30
|
+
check(`the daemon handles "${type}"`, daemon.includes(`cmd.type === '${type}'`));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------- every reply the daemon sends, the app decodes ----------
|
|
34
|
+
|
|
35
|
+
// hb is the daemon's heartbeat. It carries nothing, and the app wants it only
|
|
36
|
+
// for the fact that something arrived, which every message does: receiving it
|
|
37
|
+
// stamps lastActivity and it falls through the switch on purpose.
|
|
38
|
+
const replies = [...daemon.matchAll(/seal\(\{\s*\n?\s*type:\s*'([^']+)'/g)].map((m) => m[1]);
|
|
39
|
+
for (const type of [...new Set(replies)].sort().filter((t) => t !== 'hb')) {
|
|
40
|
+
// The app switches on envelope type. "snapshot" and the rest each get a case.
|
|
41
|
+
check(`the app decodes "${type}"`, client.includes(`case "${type}"`));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------- the launch payload ----------
|
|
45
|
+
|
|
46
|
+
check('the launch command carries a folder', /var cwd: String\?/.test(models));
|
|
47
|
+
check('the launch command carries a provider', /var provider: String\?/.test(models));
|
|
48
|
+
check('the app models the folders the Mac offers', /struct ProjectDir/.test(models));
|
|
49
|
+
check('the app models what the Mac will launch', /struct LaunchInfo/.test(models));
|
|
50
|
+
check('the host block reports launchable providers', /launch:\s*\{\s*providers:\s*launchProviders\(\)\s*\}/.test(daemon));
|
|
51
|
+
|
|
52
|
+
// ---------- the folder has to come from the Mac's own list ----------
|
|
53
|
+
// The phone must never be able to name a path. If this stops being true, a
|
|
54
|
+
// stolen channel turns into "run an agent anywhere on this Mac".
|
|
55
|
+
|
|
56
|
+
const body = daemon.slice(daemon.indexOf('async function launchSession'));
|
|
57
|
+
check('a launch validates the folder against the offered list',
|
|
58
|
+
/knownProjects\(\)\.find\(\(p\) => p\.path === cmd\.cwd\)/.test(body));
|
|
59
|
+
check('a launch refuses a folder that is not on the list',
|
|
60
|
+
body.indexOf('return fail(') < body.indexOf('osascript'));
|
|
61
|
+
|
|
62
|
+
// ---------- only what has actually been checked is offered ----------
|
|
63
|
+
// Codex and the kimi CLIs are read and written elsewhere in this daemon, but
|
|
64
|
+
// nobody has watched either of them come up from a cold terminal launch, so
|
|
65
|
+
// they are deliberately not on this list. Adding one here without checking it
|
|
66
|
+
// ships a button that may do nothing.
|
|
67
|
+
|
|
68
|
+
const providers = daemon.slice(daemon.indexOf('function launchProviders'));
|
|
69
|
+
const listed = providers.slice(0, providers.indexOf('}'));
|
|
70
|
+
check('only claude is offered for launch', /claudeBin\(\) \? \['claude'\] : \[\]/.test(listed));
|
|
71
|
+
|
|
72
|
+
console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
|
|
73
|
+
process.exit(failures ? 1 : 0);
|