@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 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.7';
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
- const claudeBin = which('claude');
642
- console.log(`[daemon] claude binary: ${claudeBin || 'NOT FOUND (phone prompts to idle sessions without a visible terminal will fail)'}`);
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
- const w = fs.watch(file, () => {
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
- if (!claudeBin) {
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(claudeBin, ['--resume', cmd.sessionId, '-p', cmd.text], {
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: codexBin, args: (id, text) => ['exec', 'resume', id, '--skip-git-repo-check', '--', text] },
1902
- kimicode: () => kimiCodeBin && { bin: kimiCodeBin, args: (id, text) => ['--session', id, '-p', defuse(text)] },
1903
- kimi: () => kimiLegacyBin && { bin: kimiLegacyBin, args: (id, text) => ['--print', '--session', id, '-p', defuse(text)] },
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, sessions will be read only'})`);
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.7",
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);