@clastres/groundcontrol 0.1.6 → 0.1.8

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/hook.mjs ADDED
@@ -0,0 +1,109 @@
1
+ // Ground Control PermissionRequest hook — runs inside Claude Code, not the daemon.
2
+ //
3
+ // Claude Code invokes this whenever a tool call needs a permission decision. It
4
+ // hands the request to the local daemon over a unix socket; the daemon decides
5
+ // whether to hold it for the phone or let it fall straight through.
6
+ //
7
+ // The one rule that matters here: when anything at all goes wrong (no daemon,
8
+ // no answer, malformed reply, broken pipe) this exits 0 printing nothing, which
9
+ // Claude Code reads as "no decision" and shows its normal terminal prompt. A
10
+ // user who has never opened the app must not be able to tell this is installed.
11
+
12
+ import fs from 'node:fs';
13
+ import net from 'node:net';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ const SOCKET = path.join(os.homedir(), '.groundcontrol', 'approvals.sock');
18
+ // Backstop only. The daemon settles every request itself, either with a
19
+ // decision or with an explicit pass, so reaching this means the daemon wedged.
20
+ const HARD_CAP_MS = 12 * 60 * 60 * 1000;
21
+ const STDIN_MS = 5000;
22
+
23
+ /// Exit without a decision. Claude Code prompts in the terminal as usual.
24
+ function pass() {
25
+ process.exit(0);
26
+ }
27
+
28
+ function decide(decision) {
29
+ // An unrecognised behaviour would be a silent deny in some Claude versions,
30
+ // so anything that is not a clean allow/deny falls back to the prompt.
31
+ if (!decision || (decision.behavior !== 'allow' && decision.behavior !== 'deny')) pass();
32
+ const out = JSON.stringify({
33
+ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision },
34
+ });
35
+ // writeSync, not process.stdout.write: stdout is a pipe here, so an async
36
+ // write followed by process.exit can truncate. Half a JSON object would be
37
+ // unparseable, which is safe (Claude prompts) but silently loses the answer.
38
+ try { fs.writeSync(1, out); } catch { /* nothing left to do but pass */ }
39
+ process.exit(0);
40
+ }
41
+
42
+ function readStdin() {
43
+ return new Promise((resolve) => {
44
+ let buf = '';
45
+ const done = (v) => { clearTimeout(timer); resolve(v); };
46
+ const timer = setTimeout(() => done(buf), STDIN_MS);
47
+ process.stdin.setEncoding('utf8');
48
+ process.stdin.on('data', (d) => { buf += d; });
49
+ process.stdin.on('end', () => done(buf));
50
+ process.stdin.on('error', () => done(''));
51
+ });
52
+ }
53
+
54
+ const raw = await readStdin();
55
+ let payload;
56
+ try { payload = JSON.parse(raw); } catch { pass(); }
57
+ if (!payload || typeof payload !== 'object') pass();
58
+
59
+ // Only session_id is genuinely required: it is what ties the request to a
60
+ // session on the island. Claude Code 2.1.220 sends no tool_use_id on this
61
+ // event (verified against a live session), and the daemon mints its own
62
+ // request id anyway, so requiring one here would drop every real request.
63
+ if (!payload.session_id) pass();
64
+
65
+ const sock = net.connect(SOCKET);
66
+ sock.setEncoding('utf8');
67
+
68
+ let settled = false;
69
+ const finish = (fn, arg) => {
70
+ if (settled) return;
71
+ settled = true;
72
+ try { sock.destroy(); } catch {}
73
+ fn(arg);
74
+ };
75
+
76
+ const cap = setTimeout(() => finish(pass), HARD_CAP_MS);
77
+ cap.unref?.();
78
+
79
+ // No daemon, stale socket file, wrong permissions: all of these land here and
80
+ // all of them mean "behave exactly like Ground Control is not installed".
81
+ sock.on('error', () => finish(pass));
82
+ // A close before any reply means the daemon died mid-decision.
83
+ sock.on('close', () => finish(pass));
84
+
85
+ sock.on('connect', () => {
86
+ sock.write(JSON.stringify({
87
+ v: 1,
88
+ sessionId: payload.session_id,
89
+ toolUseId: payload.tool_use_id || '',
90
+ toolName: payload.tool_name || 'tool',
91
+ toolInput: payload.tool_input || {},
92
+ cwd: payload.cwd || '',
93
+ permissionMode: payload.permission_mode || '',
94
+ permissionContext: payload.permission_context || null,
95
+ }) + '\n');
96
+ });
97
+
98
+ let inbox = '';
99
+ sock.on('data', (chunk) => {
100
+ inbox += chunk;
101
+ const nl = inbox.indexOf('\n');
102
+ if (nl === -1) return;
103
+ let reply;
104
+ try { reply = JSON.parse(inbox.slice(0, nl)); } catch { return finish(pass); }
105
+ // decision null is the daemon explicitly passing (you are at the Mac, the
106
+ // hold expired, or the phone never answered).
107
+ if (!reply || !reply.decision) return finish(pass);
108
+ finish(decide, reply.decision);
109
+ });
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@clastres/groundcontrol",
3
- "version": "0.1.6",
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.",
3
+ "version": "0.1.8",
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"
7
7
  },
8
+ "scripts": {
9
+ "test": "node test/run.mjs"
10
+ },
8
11
  "type": "module",
9
12
  "engines": {
10
13
  "node": ">=20"
@@ -16,6 +19,9 @@
16
19
  "keywords": [
17
20
  "claude",
18
21
  "claude-code",
22
+ "codex",
23
+ "kimi",
24
+ "grok",
19
25
  "ai",
20
26
  "agents",
21
27
  "cli",
@@ -0,0 +1,68 @@
1
+ // Agent binaries are looked up on use, not at daemon startup.
2
+ //
3
+ // This is a regression test for a real failure: the daemon had been running
4
+ // since Aug 4, Kimi Code was installed on Aug 12, and because the binary
5
+ // consts were resolved once at boot the session showed up on the phone,
6
+ // streamed its transcript, and refused every prompt with "binary not found"
7
+ // for eight days. Session discovery rescans the disk constantly; binary
8
+ // lookup did not.
9
+ //
10
+ // The resolver block is extracted from daemon.mjs rather than copied, with an
11
+ // injected clock so the one minute memo can be stepped over instantly.
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
18
+ const src = fs.readFileSync(DAEMON, 'utf8');
19
+ const start = src.indexOf('// Every agent binary is resolved on use');
20
+ const end = src.indexOf('// ---------- Claude Code session discovery ----------');
21
+ if (start < 0 || end < 0 || end < start) throw new Error('could not locate the binary resolver block in daemon.mjs');
22
+ const block = src.slice(start, end);
23
+
24
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-bin-'));
25
+ const home = path.join(tmp, 'home');
26
+ fs.mkdirSync(path.join(home, '.kimi-code', 'bin'), { recursive: true });
27
+ const kimiPath = path.join(home, '.kimi-code', 'bin', 'kimi');
28
+
29
+ let now = 1_000_000;
30
+ const clock = { now: () => now };
31
+ const fakeOs = { ...os, homedir: () => home };
32
+ const executable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { return null; } };
33
+ const which = () => null; // nothing on PATH, which is the real case here
34
+ const quiet = { log: () => {} }; // swallow the startup banner
35
+
36
+ const load = () => new Function('fs', 'os', 'path', 'Date', 'which', 'executable', 'console',
37
+ `${block}\n return { kimiCodeBin, claudeBin, codexBin, resolveBin };`)(
38
+ fs, fakeOs, path, clock, which, executable, quiet);
39
+
40
+ const { kimiCodeBin } = load();
41
+
42
+ let failures = 0;
43
+ const check = (label, cond) => { console.log(`${cond ? 'ok ' : 'FAIL'} ${label}`); if (!cond) failures++; };
44
+
45
+ // Boot: ~/.kimi-code exists but the binary is not installed yet.
46
+ check('not installed yet reads as missing', kimiCodeBin() === null);
47
+
48
+ // Kimi Code installs while the daemon keeps running. This is the exact case
49
+ // that left a session readable but unanswerable.
50
+ fs.writeFileSync(kimiPath, '#!/bin/sh\n');
51
+ fs.chmodSync(kimiPath, 0o755);
52
+
53
+ check('still cached inside the memo window', kimiCodeBin() === null);
54
+ now += 61_000;
55
+ check('picked up a minute after install', kimiCodeBin() === kimiPath);
56
+
57
+ // And the reverse: an uninstall is noticed rather than cached forever.
58
+ fs.rmSync(kimiPath);
59
+ now += 61_000;
60
+ check('uninstall clears back to missing', kimiCodeBin() === null);
61
+
62
+ // A lookup that throws must not take a prompt down with it.
63
+ const { resolveBin } = load();
64
+ check('a throwing lookup returns null', resolveBin('boom', () => { throw new Error('nope'); }) === null);
65
+
66
+ fs.rmSync(tmp, { recursive: true, force: true });
67
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
68
+ process.exit(failures ? 1 : 0);
@@ -0,0 +1,86 @@
1
+ // Runs the real Grok Bot reader out of daemon.mjs against whatever replica this
2
+ // Mac has. The block is extracted from source rather than copied, so the test
3
+ // cannot drift from what the daemon ships.
4
+ //
5
+ // The daemon is one long script that connects to the relay on import, so there
6
+ // is nothing to import: pulling the section out by its comment markers and
7
+ // evaluating it with its handful of helpers injected is what keeps this honest.
8
+ //
9
+ // On a Mac without Grok Bot installed the roster assertions are skipped and the
10
+ // machine independent ones still run.
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
17
+ const src = fs.readFileSync(DAEMON, 'utf8');
18
+ const start = src.indexOf('// ---------- Grok Bot session discovery ----------');
19
+ const end = src.indexOf('/// The phone-facing shape of a held request.');
20
+ if (start < 0 || end < 0 || end < start) throw new Error('could not locate the Grok Bot block in daemon.mjs');
21
+ const block = src.slice(start, end);
22
+
23
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
24
+ const isoOf = (t) => { try { return t ? new Date(t).toISOString() : null; } catch { return null; } };
25
+ const memoScan = (_key, _file, fn) => fn();
26
+ const transcriptCache = new Map();
27
+
28
+ const load = (source, cache) => new Function('fs', 'os', 'path', 'readJson', 'isoOf', 'memoScan', 'transcriptCache',
29
+ `${source}\n return { readGrokBotSessions, readGrokBotMessages, b32decode, GROKBOT_DIR };`)(
30
+ fs, os, path, readJson, isoOf, memoScan, cache);
31
+
32
+ const { readGrokBotSessions, readGrokBotMessages, b32decode, GROKBOT_DIR } = load(block, transcriptCache);
33
+
34
+ let failures = 0;
35
+ const check = (label, cond, detail = '') => {
36
+ console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
37
+ if (!cond) failures++;
38
+ };
39
+
40
+ // A store key the app really writes, so a change to the encoding is caught here
41
+ // rather than by an empty island.
42
+ const known = 'onqw4zbomnwgszlooqxhg3djmnss4y3mnfsw45bnnvsxiyjomfrwg33vnz2c243mn52a';
43
+ check('b32decode reads a real store key', b32decode(known) === 'sand.client.slice.client-meta.account-slot', b32decode(known));
44
+ check('b32decode rejects junk', b32decode('!!!!') === '');
45
+
46
+ // A Mac without Grok Bot must return an empty list rather than throw.
47
+ check('missing install returns empty, does not throw',
48
+ load(block.replace(/const GROKBOT_DIR = .*/, "const GROKBOT_DIR = '/nope/not/here';"), new Map())
49
+ .readGrokBotSessions().length === 0);
50
+
51
+ const sessions = fs.existsSync(GROKBOT_DIR) ? readGrokBotSessions() : [];
52
+ if (!sessions.length) {
53
+ console.log(`\nskipped the roster checks: no Bots found in ${GROKBOT_DIR}`);
54
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
55
+ process.exit(failures ? 1 : 0);
56
+ }
57
+
58
+ console.log(`\n${sessions.length} bot(s) found\n`);
59
+ for (const s of sessions) {
60
+ check(`${s.name}: id is a uuid`, /^[0-9a-f-]{36}$/.test(s.id));
61
+ check(`${s.name}: status is one the app understands`, ['idle', 'busy', 'waiting'].includes(s.status));
62
+ check(`${s.name}: has a name`, !!s.name);
63
+ check(`${s.name}: startedAt set`, s.startedAt > 0);
64
+ check(`${s.name}: updatedAt set`, s.updatedAt > 0);
65
+ check(`${s.name}: read only flagged`, s.readOnly === true);
66
+ // Every field the Swift Session struct decodes as non-optional must be here,
67
+ // or the phone drops the whole snapshot, not just this row.
68
+ for (const k of ['id', 'name', 'cwd', 'project', 'status', 'updatedAt', 'startedAt']) {
69
+ check(`${s.name}: ${k} present for Swift decode`, s[k] !== undefined && s[k] !== null);
70
+ }
71
+
72
+ const file = transcriptCache.get(s.id);
73
+ check(`${s.name}: transcript located`, !!file);
74
+ if (!file) continue;
75
+ const msgs = readGrokBotMessages(file);
76
+ check(`${s.name}: transcript has messages`, msgs.length > 0);
77
+ check(`${s.name}: roles are user/assistant only`, msgs.every((m) => m.role === 'user' || m.role === 'assistant'));
78
+ check(`${s.name}: every message has a timestamp`, msgs.every((m) => !!m.ts));
79
+ check(`${s.name}: lastMessage matches transcript tail`, s.lastMessage?.text === msgs[msgs.length - 1]?.text);
80
+ // The app stores a ProseMirror doc beside every typed turn. Leaking it into
81
+ // the transcript would put raw JSON on the phone.
82
+ check(`${s.name}: no raw richText leaked into text`, msgs.every((m) => !m.text.includes('"type":"doc"')));
83
+ }
84
+
85
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
86
+ process.exit(failures ? 1 : 0);
package/test/run.mjs ADDED
@@ -0,0 +1,23 @@
1
+ // Every test in this directory, in one run. Plain node, no framework, matching
2
+ // the rest of the CLI: the daemon ships with two runtime dependencies and a
3
+ // test runner is not going to be the third.
4
+ import { execFileSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const dir = path.dirname(fileURLToPath(import.meta.url));
10
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.mjs') && f !== 'run.mjs').sort();
11
+
12
+ let failed = 0;
13
+ for (const f of files) {
14
+ console.log(`\n=== ${f} ===`);
15
+ try {
16
+ execFileSync(process.execPath, [path.join(dir, f)], { stdio: 'inherit' });
17
+ } catch {
18
+ failed++;
19
+ }
20
+ }
21
+
22
+ console.log(failed ? `\n${failed} of ${files.length} test file(s) failed` : `\n${files.length} test file(s) passed`);
23
+ process.exit(failed ? 1 : 0);