@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 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.9';
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.9",
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"
@@ -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);