@clastres/groundcontrol 0.1.7 → 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/daemon.mjs +205 -17
- package/package.json +8 -2
- package/test/binaries.mjs +68 -0
- package/test/grokbot.mjs +86 -0
- package/test/run.mjs +23 -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.8';
|
|
26
26
|
const PACKAGE = '@clastres/groundcontrol';
|
|
27
27
|
|
|
28
28
|
// ---------- config / pairing ----------
|
|
@@ -638,23 +638,40 @@ widenPath();
|
|
|
638
638
|
const which = (cmd) => { try { return execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch { return null; } };
|
|
639
639
|
const executable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { return null; } };
|
|
640
640
|
|
|
641
|
-
|
|
642
|
-
|
|
641
|
+
// Every agent binary is resolved on use rather than at startup. launchd starts
|
|
642
|
+
// this at login and it then runs for weeks, while CLIs get installed
|
|
643
|
+
// underneath it. Session discovery already rescans the disk every few seconds,
|
|
644
|
+
// so a `const` resolved at boot produces the worst possible split: the new
|
|
645
|
+
// session appears on the phone, its transcript streams, and sending fails with
|
|
646
|
+
// "binary not found" until someone restarts the daemon. Cached for a minute so
|
|
647
|
+
// a prompt does not pay for a PATH scan.
|
|
648
|
+
const binMemo = new Map(); // label -> { at, path }
|
|
649
|
+
function resolveBin(label, find) {
|
|
650
|
+
const c = binMemo.get(label);
|
|
651
|
+
if (c && Date.now() - c.at < 60_000) return c.path;
|
|
652
|
+
let p = null;
|
|
653
|
+
try { p = find() || null; } catch {}
|
|
654
|
+
binMemo.set(label, { at: Date.now(), path: p });
|
|
655
|
+
return p;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const claudeBin = () => resolveBin('claude', () => which('claude'));
|
|
659
|
+
console.log(`[daemon] claude binary: ${claudeBin() || 'NOT FOUND (phone prompts to idle sessions without a visible terminal will fail)'}`);
|
|
643
660
|
|
|
644
661
|
// Codex ships a full CLI inside the ChatGPT desktop app, so Codex Desktop
|
|
645
662
|
// users can receive phone prompts even without a standalone install.
|
|
646
|
-
const codexBin = which('codex') || executable('/Applications/ChatGPT.app/Contents/Resources/codex');
|
|
663
|
+
const codexBin = () => resolveBin('codex', () => which('codex') || executable('/Applications/ChatGPT.app/Contents/Resources/codex'));
|
|
647
664
|
|
|
648
665
|
// Kimi Code renames a legacy Python `kimi` shim to `kimi-legacy` on install,
|
|
649
666
|
// so when the Kimi Code store (~/.kimi-code) exists, `kimi` on PATH belongs
|
|
650
667
|
// to Kimi Code and `kimi-legacy` is the old CLI. Without the store, `kimi` is
|
|
651
668
|
// the legacy one. The two binaries cannot resume each other's sessions, so
|
|
652
669
|
// neither ever doubles for the other.
|
|
653
|
-
const kimiCodeInstalled = fs.existsSync(path.join(os.homedir(), '.kimi-code'));
|
|
654
|
-
const kimiCodeBin = kimiCodeInstalled
|
|
670
|
+
const kimiCodeInstalled = () => fs.existsSync(path.join(os.homedir(), '.kimi-code'));
|
|
671
|
+
const kimiCodeBin = () => resolveBin('kimicode', () => (kimiCodeInstalled()
|
|
655
672
|
? which('kimi') || executable(path.join(os.homedir(), '.kimi-code', 'bin', 'kimi'))
|
|
656
|
-
: null;
|
|
657
|
-
const kimiLegacyBin = which('kimi-legacy') || (kimiCodeInstalled ? null : which('kimi'));
|
|
673
|
+
: null));
|
|
674
|
+
const kimiLegacyBin = () => resolveBin('kimi', () => which('kimi-legacy') || (kimiCodeInstalled() ? null : which('kimi')));
|
|
658
675
|
|
|
659
676
|
// ---------- Claude Code session discovery ----------
|
|
660
677
|
|
|
@@ -850,6 +867,7 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
|
|
|
850
867
|
// path.sep keeps the prefixes unambiguous: without it ~/.kimi would also
|
|
851
868
|
// match every ~/.kimi-code path and routing would depend on check order.
|
|
852
869
|
if (file.startsWith(CODEX_SESS_DIR + path.sep)) return readCodexMessages(file, maxBytes, limit);
|
|
870
|
+
if (file.startsWith(GROKBOT_DIR + path.sep)) return readGrokBotMessages(file, maxBytes, limit);
|
|
853
871
|
if (file.startsWith(KIMICODE_DIR + path.sep)) return readKimiCodeMessages(file, maxBytes, limit);
|
|
854
872
|
if (file.startsWith(KIMI_DIR + path.sep)) return readKimiLegacyMessages(file, maxBytes, limit);
|
|
855
873
|
let raw;
|
|
@@ -1312,6 +1330,157 @@ function readKimiSessions() {
|
|
|
1312
1330
|
return kimiCache.sessions;
|
|
1313
1331
|
}
|
|
1314
1332
|
|
|
1333
|
+
// ---------- Grok Bot session discovery ----------
|
|
1334
|
+
// Grok Bot's agents run on a cloud VM, so unlike every other provider there is
|
|
1335
|
+
// nothing local to attach to: no process, no tty, no working directory, and no
|
|
1336
|
+
// way to send. What the desktop app does keep is a replica of what it has
|
|
1337
|
+
// synced, under ~/Library/Application Support/Grok Bot/sand-client-persistence,
|
|
1338
|
+
// one JSON blob per store key with the key base32 encoded into the filename.
|
|
1339
|
+
// Two keys matter:
|
|
1340
|
+
// <account>.roster.last-roster one row per Bot
|
|
1341
|
+
// <account>.transcript.replicas.<id> that Bot's conversation
|
|
1342
|
+
//
|
|
1343
|
+
// Read only, deliberately. The replica is the app's cache and not the source of
|
|
1344
|
+
// truth (that lives on the VM at /home/box/sand-data/agents/<id>/store.db), so
|
|
1345
|
+
// a message written in here is a message the app overwrites rather than sends.
|
|
1346
|
+
//
|
|
1347
|
+
// It also only advances while the desktop app is running. Bots keep working
|
|
1348
|
+
// with the app closed and the island cannot see it, so a Bot left mid turn is
|
|
1349
|
+
// the app having quit, not the Bot being stuck: busy expires on staleness the
|
|
1350
|
+
// same way the other journal-freshness providers do.
|
|
1351
|
+
|
|
1352
|
+
const GROKBOT_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'Grok Bot', 'sand-client-persistence');
|
|
1353
|
+
const GROKBOT_BUSY_STALE_MS = 5 * 60 * 1000;
|
|
1354
|
+
|
|
1355
|
+
// RFC 4648 base32, lowercase and unpadded, which is how the app names blobs.
|
|
1356
|
+
// A name that is not one of ours decodes to junk and simply matches nothing,
|
|
1357
|
+
// so guessing wrong costs a string compare.
|
|
1358
|
+
const B32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
1359
|
+
function b32decode(name) {
|
|
1360
|
+
let bits = 0;
|
|
1361
|
+
let value = 0;
|
|
1362
|
+
const out = [];
|
|
1363
|
+
for (const ch of name.toLowerCase()) {
|
|
1364
|
+
if (ch === '=') break;
|
|
1365
|
+
const i = B32_ALPHABET.indexOf(ch);
|
|
1366
|
+
if (i < 0) return '';
|
|
1367
|
+
value = (value << 5) | i;
|
|
1368
|
+
bits += 5;
|
|
1369
|
+
if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
|
|
1370
|
+
}
|
|
1371
|
+
return Buffer.from(out).toString('utf8');
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// The names are long and never change, so decode each one once.
|
|
1375
|
+
const grokbotKeys = new Map(); // filename -> decoded store key
|
|
1376
|
+
const grokbotKeyOf = (name) => {
|
|
1377
|
+
if (!grokbotKeys.has(name)) grokbotKeys.set(name, b32decode(name.replace(/\.blob$/, '')));
|
|
1378
|
+
return grokbotKeys.get(name);
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
const grokbotEntriesOf = (file) => {
|
|
1382
|
+
const entries = readJson(file)?.value?.entries;
|
|
1383
|
+
return Array.isArray(entries) ? entries : [];
|
|
1384
|
+
};
|
|
1385
|
+
|
|
1386
|
+
// Two entry kinds carry a turn: a typed one is {kind:'message', role, content}
|
|
1387
|
+
// and a Bot one is {kind:'send-message', message:{type,content}}. Everything
|
|
1388
|
+
// else in the stream (tool calls, approvals, status) has no place in a phone
|
|
1389
|
+
// transcript.
|
|
1390
|
+
function grokbotMessages(entries, limit = 80) {
|
|
1391
|
+
const out = [];
|
|
1392
|
+
for (const e of entries) {
|
|
1393
|
+
let role = null;
|
|
1394
|
+
let text = '';
|
|
1395
|
+
if (e?.kind === 'message' && (e.role === 'user' || e.role === 'assistant')) {
|
|
1396
|
+
role = e.role;
|
|
1397
|
+
text = typeof e.content === 'string' ? e.content : '';
|
|
1398
|
+
} else if (e?.kind === 'send-message' && e.message?.type === 'text') {
|
|
1399
|
+
role = 'assistant';
|
|
1400
|
+
text = typeof e.message.content === 'string' ? e.message.content : '';
|
|
1401
|
+
}
|
|
1402
|
+
text = text.trim();
|
|
1403
|
+
if (!role || !text) continue;
|
|
1404
|
+
out.push({ role, text: text.slice(0, 4000), ts: isoOf(e.timestampMs) });
|
|
1405
|
+
}
|
|
1406
|
+
return out.slice(-limit);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// One JSON object rather than a journal, so the tail slice every other provider
|
|
1410
|
+
// reads with would just be a syntax error. Whole file, with a ceiling that only
|
|
1411
|
+
// exists so a pathological blob cannot stall the daemon.
|
|
1412
|
+
function readGrokBotMessages(file, maxBytes = 512 * 1024, limit = 80) {
|
|
1413
|
+
try { if (fs.statSync(file).size > 8 * 1024 * 1024) return []; } catch { return []; }
|
|
1414
|
+
return grokbotMessages(grokbotEntriesOf(file), limit);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function scanGrokBotSessions() {
|
|
1418
|
+
const sessions = [];
|
|
1419
|
+
let files = [];
|
|
1420
|
+
try { files = fs.readdirSync(GROKBOT_DIR); } catch { return sessions; }
|
|
1421
|
+
|
|
1422
|
+
let rosterFile = null;
|
|
1423
|
+
const transcripts = new Map(); // botId -> blob path
|
|
1424
|
+
for (const name of files) {
|
|
1425
|
+
if (!name.endsWith('.blob')) continue;
|
|
1426
|
+
const key = grokbotKeyOf(name);
|
|
1427
|
+
if (key.endsWith('.roster.last-roster')) rosterFile = path.join(GROKBOT_DIR, name);
|
|
1428
|
+
else {
|
|
1429
|
+
const m = key.match(/\.transcript\.replicas\.([0-9a-f-]{36})$/);
|
|
1430
|
+
if (m) transcripts.set(m[1], path.join(GROKBOT_DIR, name));
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
const rows = rosterFile ? readJson(rosterFile)?.value?.rows : null;
|
|
1434
|
+
if (!Array.isArray(rows)) return sessions;
|
|
1435
|
+
|
|
1436
|
+
for (const row of rows) {
|
|
1437
|
+
// A Bot is a standing teammate rather than a session that ends, so the
|
|
1438
|
+
// roster is mirrored as it stands: what the Grok Bot sidebar lists, the
|
|
1439
|
+
// island shows.
|
|
1440
|
+
if (!row?.id || row.isHiddenFromSidebar) continue;
|
|
1441
|
+
const file = transcripts.get(row.id) || null;
|
|
1442
|
+
const scan = file
|
|
1443
|
+
? memoScan(`grokbot:${file}`, file, () => {
|
|
1444
|
+
const entries = grokbotEntriesOf(file);
|
|
1445
|
+
return { messages: grokbotMessages(entries, 1), streaming: entries.some((e) => e?.isStreaming === true) };
|
|
1446
|
+
})
|
|
1447
|
+
: { messages: [], streaming: false };
|
|
1448
|
+
let updatedAt = row.lastActivityAt || row.updatedAt || 0;
|
|
1449
|
+
if (file) { try { updatedAt = Math.max(updatedAt, fs.statSync(file).mtimeMs); } catch {} }
|
|
1450
|
+
const busy = scan.streaming && Date.now() - updatedAt < GROKBOT_BUSY_STALE_MS;
|
|
1451
|
+
if (file) transcriptCache.set(row.id, file);
|
|
1452
|
+
sessions.push({
|
|
1453
|
+
id: row.id,
|
|
1454
|
+
provider: 'grokbot',
|
|
1455
|
+
name: row.name || 'Bot',
|
|
1456
|
+
// Its working directory is on a machine that is not this one, and
|
|
1457
|
+
// claiming otherwise would put a path on the island you cannot open.
|
|
1458
|
+
cwd: '',
|
|
1459
|
+
project: 'Grok Bot',
|
|
1460
|
+
// awaitingUserResponse is how the app marks a Bot stopped on you, which
|
|
1461
|
+
// is the same pink invader as a held permission request.
|
|
1462
|
+
status: row.awaitingUserResponse ? 'waiting' : (busy ? 'busy' : 'idle'),
|
|
1463
|
+
updatedAt,
|
|
1464
|
+
startedAt: row.createdAt || 0,
|
|
1465
|
+
lastMessage: scan.messages.length ? scan.messages[scan.messages.length - 1] : null,
|
|
1466
|
+
tty: '',
|
|
1467
|
+
live: false,
|
|
1468
|
+
// Nothing reads this yet. It is what a build that greys out the compose
|
|
1469
|
+
// box will key on, instead of re-deriving read-only from the provider.
|
|
1470
|
+
readOnly: true,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
return sessions;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
let grokbotCache = { at: 0, sessions: [] };
|
|
1477
|
+
function readGrokBotSessions() {
|
|
1478
|
+
if (Date.now() - grokbotCache.at < 10_000) return grokbotCache.sessions;
|
|
1479
|
+
grokbotCache.at = Date.now();
|
|
1480
|
+
grokbotCache.sessions = scanGrokBotSessions();
|
|
1481
|
+
return grokbotCache.sessions;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1315
1484
|
/// The phone-facing shape of a held request.
|
|
1316
1485
|
const askPayload = (ask, queued) => ({
|
|
1317
1486
|
id: ask.id,
|
|
@@ -1323,7 +1492,7 @@ const askPayload = (ask, queued) => ({
|
|
|
1323
1492
|
});
|
|
1324
1493
|
|
|
1325
1494
|
function readSessions() {
|
|
1326
|
-
const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
|
|
1495
|
+
const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions(), ...readGrokBotSessions()];
|
|
1327
1496
|
const known = new Set(sessions.map((s) => s.id));
|
|
1328
1497
|
for (const s of sessions) {
|
|
1329
1498
|
const queue = askQueue.get(s.id);
|
|
@@ -1758,7 +1927,14 @@ function watchTranscript(sessionId) {
|
|
|
1758
1927
|
if (!file) return;
|
|
1759
1928
|
let debounce;
|
|
1760
1929
|
try {
|
|
1761
|
-
|
|
1930
|
+
// Journals are appended to, so watching the file is enough. Grok Bot
|
|
1931
|
+
// rewrites its replica blob wholesale, and a rename leaves a file watch
|
|
1932
|
+
// bound to an inode nothing writes to again: watch the directory instead
|
|
1933
|
+
// and filter, which survives the swap.
|
|
1934
|
+
const inGrokBot = file.startsWith(GROKBOT_DIR + path.sep);
|
|
1935
|
+
const target = inGrokBot ? GROKBOT_DIR : file;
|
|
1936
|
+
const w = fs.watch(target, (_ev, changed) => {
|
|
1937
|
+
if (inGrokBot && changed && changed !== path.basename(file)) return;
|
|
1762
1938
|
clearTimeout(debounce);
|
|
1763
1939
|
debounce = setTimeout(() => {
|
|
1764
1940
|
ws?.send(seal({ type: 'transcript', sessionId, messages: readMessages(sessionId) }));
|
|
@@ -1828,6 +2004,15 @@ async function handleCommand(cmd) {
|
|
|
1828
2004
|
const session = readSessions().find((s) => s.id === cmd.sessionId);
|
|
1829
2005
|
if (!session) return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' }));
|
|
1830
2006
|
|
|
2007
|
+
// A Bot lives on a cloud VM this daemon has no channel to. Saying so beats
|
|
2008
|
+
// a send that looks accepted and arrives nowhere.
|
|
2009
|
+
if (session.provider === 'grokbot') {
|
|
2010
|
+
return ws?.send(seal({
|
|
2011
|
+
type: 'promptResult', sessionId: cmd.sessionId, ok: false,
|
|
2012
|
+
error: 'Grok Bot runs in the cloud, so Ground Control can only watch it. Open the Grok Bot app to reply.',
|
|
2013
|
+
}));
|
|
2014
|
+
}
|
|
2015
|
+
|
|
1831
2016
|
if (session.provider && session.provider !== 'claude') return promptHeadless(session, cmd.text);
|
|
1832
2017
|
|
|
1833
2018
|
// Preferred path: type into the real terminal so the desktop updates live.
|
|
@@ -1849,7 +2034,8 @@ async function handleCommand(cmd) {
|
|
|
1849
2034
|
}
|
|
1850
2035
|
}
|
|
1851
2036
|
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' }));
|
|
1852
|
-
|
|
2037
|
+
const bin = claudeBin();
|
|
2038
|
+
if (!bin) {
|
|
1853
2039
|
console.log('[daemon] prompt failed: claude binary not found on PATH');
|
|
1854
2040
|
return ws?.send(seal({
|
|
1855
2041
|
type: 'promptResult', sessionId: cmd.sessionId, ok: false,
|
|
@@ -1859,7 +2045,7 @@ async function handleCommand(cmd) {
|
|
|
1859
2045
|
console.log(`[daemon] prompt -> ${session.name}: ${cmd.text.slice(0, 60)}`);
|
|
1860
2046
|
headlessActive.add(cmd.sessionId);
|
|
1861
2047
|
sendSnapshot(true);
|
|
1862
|
-
const child = spawn(
|
|
2048
|
+
const child = spawn(bin, ['--resume', cmd.sessionId, '-p', cmd.text], {
|
|
1863
2049
|
cwd: fs.existsSync(session.cwd || '') ? session.cwd : os.homedir(),
|
|
1864
2050
|
env: process.env,
|
|
1865
2051
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -1898,14 +2084,15 @@ async function handleCommand(cmd) {
|
|
|
1898
2084
|
// dash there (the models do not care about leading whitespace).
|
|
1899
2085
|
const defuse = (text) => (/^\s*-/.test(text) ? ` ${text}` : text);
|
|
1900
2086
|
const headlessPromptSpec = {
|
|
1901
|
-
codex: () => codexBin && { bin
|
|
1902
|
-
kimicode: () => kimiCodeBin && { bin
|
|
1903
|
-
kimi: () => kimiLegacyBin && { bin
|
|
2087
|
+
codex: () => { const bin = codexBin(); return bin && { bin, args: (id, text) => ['exec', 'resume', id, '--skip-git-repo-check', '--', text] }; },
|
|
2088
|
+
kimicode: () => { const bin = kimiCodeBin(); return bin && { bin, args: (id, text) => ['--session', id, '-p', defuse(text)] }; },
|
|
2089
|
+
kimi: () => { const bin = kimiLegacyBin(); return bin && { bin, args: (id, text) => ['--print', '--session', id, '-p', defuse(text)] }; },
|
|
1904
2090
|
};
|
|
1905
2091
|
|
|
1906
2092
|
function bustProviderCaches() {
|
|
1907
2093
|
codexCache.at = 0;
|
|
1908
2094
|
kimiCache.at = 0;
|
|
2095
|
+
grokbotCache.at = 0;
|
|
1909
2096
|
}
|
|
1910
2097
|
|
|
1911
2098
|
function promptHeadless(session, text) {
|
|
@@ -1918,7 +2105,7 @@ function promptHeadless(session, text) {
|
|
|
1918
2105
|
console.log(`[daemon] ${session.provider} prompt failed: binary not found`);
|
|
1919
2106
|
return ws?.send(seal({
|
|
1920
2107
|
type: 'promptResult', sessionId: session.id, ok: false,
|
|
1921
|
-
error: `The daemon could not find the ${session.provider === 'codex' ? 'codex' : 'kimi'} command on this Mac, so it cannot reach this session.`,
|
|
2108
|
+
error: `The daemon could not find the ${session.provider === 'codex' ? 'codex' : 'kimi'} command on this Mac, so it cannot reach this session. If you just installed it, try again in a minute.`,
|
|
1922
2109
|
}));
|
|
1923
2110
|
}
|
|
1924
2111
|
console.log(`[daemon] prompt -> ${session.provider} ${session.name}: ${text.slice(0, 60)}`);
|
|
@@ -2060,6 +2247,7 @@ function watchProviderDir(dir, cache, label) {
|
|
|
2060
2247
|
console.log(`[daemon] ${label}: watching ${dir}`);
|
|
2061
2248
|
} catch {}
|
|
2062
2249
|
}
|
|
2063
|
-
watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin || 'not found,
|
|
2250
|
+
watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin() || 'not found yet, rechecked on each prompt'})`);
|
|
2064
2251
|
watchProviderDir(path.join(KIMI_DIR, 'sessions'), kimiCache, 'kimi sessions');
|
|
2065
2252
|
watchProviderDir(path.join(KIMICODE_DIR, 'sessions'), kimiCache, 'kimi code sessions');
|
|
2253
|
+
watchProviderDir(GROKBOT_DIR, grokbotCache, 'grok bot roster (read only, replies happen in the Grok Bot app)');
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "See and command your
|
|
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);
|
package/test/grokbot.mjs
ADDED
|
@@ -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);
|