@clastres/groundcontrol 0.1.9 → 0.1.11

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.11';
26
26
  const PACKAGE = '@clastres/groundcontrol';
27
27
 
28
28
  // ---------- config / pairing ----------
@@ -867,7 +867,6 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
867
867
  // path.sep keeps the prefixes unambiguous: without it ~/.kimi would also
868
868
  // match every ~/.kimi-code path and routing would depend on check order.
869
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);
871
870
  if (file.startsWith(KIMICODE_DIR + path.sep)) return readKimiCodeMessages(file, maxBytes, limit);
872
871
  if (file.startsWith(KIMI_DIR + path.sep)) return readKimiLegacyMessages(file, maxBytes, limit);
873
872
  let raw;
@@ -996,6 +995,15 @@ function codexMetaOf(file) {
996
995
  id: j.payload.session_id,
997
996
  cwd: j.payload.cwd || '',
998
997
  startedAt: Date.parse(j.payload.timestamp || j.timestamp) || 0,
998
+ // Codex spawns its own helpers (the review subagent, for one) as
999
+ // separate rollout files that carry the *parent's* session_id. Two
1000
+ // files, one id: the phone would draw one pilot and leave a hole
1001
+ // where the other should be, because both the crew list and the
1002
+ // island key on the session id. A helper is not a pilot anyway, so
1003
+ // it is marked here and dropped by the scan, which keeps the answer
1004
+ // in the cache rather than re-reading a growing file every sweep.
1005
+ helper: j.payload.thread_source === 'subagent'
1006
+ || (!!j.payload.parent_thread_id && j.payload.parent_thread_id !== j.payload.id),
999
1007
  };
1000
1008
  }
1001
1009
  } catch {}
@@ -1090,17 +1098,29 @@ function readCodexSessions() {
1090
1098
  codexCache.at = Date.now();
1091
1099
  const names = codexThreadNames();
1092
1100
  const sessions = [];
1101
+ // One pilot per folder, not one per run.
1102
+ //
1103
+ // Codex has no long lived session the way Claude Code does: every `codex
1104
+ // exec` writes its own rollout, so asking it to review a change three times
1105
+ // in an afternoon used to put three identical pilots on the island, all in
1106
+ // the same directory, none of them the one you meant. The freshest rollout
1107
+ // in a folder is the one worth talking to, and prompting resumes it by id,
1108
+ // so the older ones have nothing left to offer.
1109
+ const newestPerCwd = new Map(); // cwd -> index into sessions
1093
1110
  for (const file of codexRolloutFiles()) {
1094
1111
  let mtime;
1095
1112
  try { mtime = fs.statSync(file).mtimeMs; } catch { continue; }
1096
1113
  if (Date.now() - mtime > CODEX_ACTIVE_WINDOW_MS) continue;
1097
1114
  const meta = codexMetaOf(file);
1098
- if (!meta) continue;
1115
+ if (!meta || meta.helper) continue;
1099
1116
  const act = memoScan(`codex:${file}`, file, () => codexActivityOf(file));
1100
1117
  const busy = (act.busy || (!act.sawMarkers && Date.now() - mtime < 60_000))
1101
1118
  && Date.now() - mtime < CODEX_BUSY_STALE_MS;
1119
+ const key = meta.cwd || `id:${meta.id}`;
1120
+ const seen = newestPerCwd.get(key);
1121
+ if (seen !== undefined && sessions[seen].updatedAt >= mtime) continue;
1102
1122
  transcriptCache.set(meta.id, file);
1103
- sessions.push({
1123
+ const session = {
1104
1124
  id: meta.id,
1105
1125
  provider: 'codex',
1106
1126
  name: names.get(meta.id) || path.basename(meta.cwd || '?'),
@@ -1112,7 +1132,9 @@ function readCodexSessions() {
1112
1132
  lastMessage: act.lastMessage,
1113
1133
  tty: '',
1114
1134
  live: false,
1115
- });
1135
+ };
1136
+ if (seen !== undefined) sessions[seen] = session;
1137
+ else { newestPerCwd.set(key, sessions.length); sessions.push(session); }
1116
1138
  }
1117
1139
  codexCache.sessions = sessions;
1118
1140
  return sessions;
@@ -1439,157 +1461,6 @@ function readKimiSessions() {
1439
1461
  return kimiCache.sessions;
1440
1462
  }
1441
1463
 
1442
- // ---------- Grok Bot session discovery ----------
1443
- // Grok Bot's agents run on a cloud VM, so unlike every other provider there is
1444
- // nothing local to attach to: no process, no tty, no working directory, and no
1445
- // way to send. What the desktop app does keep is a replica of what it has
1446
- // synced, under ~/Library/Application Support/Grok Bot/sand-client-persistence,
1447
- // one JSON blob per store key with the key base32 encoded into the filename.
1448
- // Two keys matter:
1449
- // <account>.roster.last-roster one row per Bot
1450
- // <account>.transcript.replicas.<id> that Bot's conversation
1451
- //
1452
- // Read only, deliberately. The replica is the app's cache and not the source of
1453
- // truth (that lives on the VM at /home/box/sand-data/agents/<id>/store.db), so
1454
- // a message written in here is a message the app overwrites rather than sends.
1455
- //
1456
- // It also only advances while the desktop app is running. Bots keep working
1457
- // with the app closed and the island cannot see it, so a Bot left mid turn is
1458
- // the app having quit, not the Bot being stuck: busy expires on staleness the
1459
- // same way the other journal-freshness providers do.
1460
-
1461
- const GROKBOT_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'Grok Bot', 'sand-client-persistence');
1462
- const GROKBOT_BUSY_STALE_MS = 5 * 60 * 1000;
1463
-
1464
- // RFC 4648 base32, lowercase and unpadded, which is how the app names blobs.
1465
- // A name that is not one of ours decodes to junk and simply matches nothing,
1466
- // so guessing wrong costs a string compare.
1467
- const B32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
1468
- function b32decode(name) {
1469
- let bits = 0;
1470
- let value = 0;
1471
- const out = [];
1472
- for (const ch of name.toLowerCase()) {
1473
- if (ch === '=') break;
1474
- const i = B32_ALPHABET.indexOf(ch);
1475
- if (i < 0) return '';
1476
- value = (value << 5) | i;
1477
- bits += 5;
1478
- if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
1479
- }
1480
- return Buffer.from(out).toString('utf8');
1481
- }
1482
-
1483
- // The names are long and never change, so decode each one once.
1484
- const grokbotKeys = new Map(); // filename -> decoded store key
1485
- const grokbotKeyOf = (name) => {
1486
- if (!grokbotKeys.has(name)) grokbotKeys.set(name, b32decode(name.replace(/\.blob$/, '')));
1487
- return grokbotKeys.get(name);
1488
- };
1489
-
1490
- const grokbotEntriesOf = (file) => {
1491
- const entries = readJson(file)?.value?.entries;
1492
- return Array.isArray(entries) ? entries : [];
1493
- };
1494
-
1495
- // Two entry kinds carry a turn: a typed one is {kind:'message', role, content}
1496
- // and a Bot one is {kind:'send-message', message:{type,content}}. Everything
1497
- // else in the stream (tool calls, approvals, status) has no place in a phone
1498
- // transcript.
1499
- function grokbotMessages(entries, limit = 80) {
1500
- const out = [];
1501
- for (const e of entries) {
1502
- let role = null;
1503
- let text = '';
1504
- if (e?.kind === 'message' && (e.role === 'user' || e.role === 'assistant')) {
1505
- role = e.role;
1506
- text = typeof e.content === 'string' ? e.content : '';
1507
- } else if (e?.kind === 'send-message' && e.message?.type === 'text') {
1508
- role = 'assistant';
1509
- text = typeof e.message.content === 'string' ? e.message.content : '';
1510
- }
1511
- text = text.trim();
1512
- if (!role || !text) continue;
1513
- out.push({ role, text: text.slice(0, 4000), ts: isoOf(e.timestampMs) });
1514
- }
1515
- return out.slice(-limit);
1516
- }
1517
-
1518
- // One JSON object rather than a journal, so the tail slice every other provider
1519
- // reads with would just be a syntax error. Whole file, with a ceiling that only
1520
- // exists so a pathological blob cannot stall the daemon.
1521
- function readGrokBotMessages(file, maxBytes = 512 * 1024, limit = 80) {
1522
- try { if (fs.statSync(file).size > 8 * 1024 * 1024) return []; } catch { return []; }
1523
- return grokbotMessages(grokbotEntriesOf(file), limit);
1524
- }
1525
-
1526
- function scanGrokBotSessions() {
1527
- const sessions = [];
1528
- let files = [];
1529
- try { files = fs.readdirSync(GROKBOT_DIR); } catch { return sessions; }
1530
-
1531
- let rosterFile = null;
1532
- const transcripts = new Map(); // botId -> blob path
1533
- for (const name of files) {
1534
- if (!name.endsWith('.blob')) continue;
1535
- const key = grokbotKeyOf(name);
1536
- if (key.endsWith('.roster.last-roster')) rosterFile = path.join(GROKBOT_DIR, name);
1537
- else {
1538
- const m = key.match(/\.transcript\.replicas\.([0-9a-f-]{36})$/);
1539
- if (m) transcripts.set(m[1], path.join(GROKBOT_DIR, name));
1540
- }
1541
- }
1542
- const rows = rosterFile ? readJson(rosterFile)?.value?.rows : null;
1543
- if (!Array.isArray(rows)) return sessions;
1544
-
1545
- for (const row of rows) {
1546
- // A Bot is a standing teammate rather than a session that ends, so the
1547
- // roster is mirrored as it stands: what the Grok Bot sidebar lists, the
1548
- // island shows.
1549
- if (!row?.id || row.isHiddenFromSidebar) continue;
1550
- const file = transcripts.get(row.id) || null;
1551
- const scan = file
1552
- ? memoScan(`grokbot:${file}`, file, () => {
1553
- const entries = grokbotEntriesOf(file);
1554
- return { messages: grokbotMessages(entries, 1), streaming: entries.some((e) => e?.isStreaming === true) };
1555
- })
1556
- : { messages: [], streaming: false };
1557
- let updatedAt = row.lastActivityAt || row.updatedAt || 0;
1558
- if (file) { try { updatedAt = Math.max(updatedAt, fs.statSync(file).mtimeMs); } catch {} }
1559
- const busy = scan.streaming && Date.now() - updatedAt < GROKBOT_BUSY_STALE_MS;
1560
- if (file) transcriptCache.set(row.id, file);
1561
- sessions.push({
1562
- id: row.id,
1563
- provider: 'grokbot',
1564
- name: row.name || 'Bot',
1565
- // Its working directory is on a machine that is not this one, and
1566
- // claiming otherwise would put a path on the island you cannot open.
1567
- cwd: '',
1568
- project: 'Grok Bot',
1569
- // awaitingUserResponse is how the app marks a Bot stopped on you, which
1570
- // is the same pink invader as a held permission request.
1571
- status: row.awaitingUserResponse ? 'waiting' : (busy ? 'busy' : 'idle'),
1572
- updatedAt,
1573
- startedAt: row.createdAt || 0,
1574
- lastMessage: scan.messages.length ? scan.messages[scan.messages.length - 1] : null,
1575
- tty: '',
1576
- live: false,
1577
- // Nothing reads this yet. It is what a build that greys out the compose
1578
- // box will key on, instead of re-deriving read-only from the provider.
1579
- readOnly: true,
1580
- });
1581
- }
1582
- return sessions;
1583
- }
1584
-
1585
- let grokbotCache = { at: 0, sessions: [] };
1586
- function readGrokBotSessions() {
1587
- if (Date.now() - grokbotCache.at < 10_000) return grokbotCache.sessions;
1588
- grokbotCache.at = Date.now();
1589
- grokbotCache.sessions = scanGrokBotSessions();
1590
- return grokbotCache.sessions;
1591
- }
1592
-
1593
1464
  /// The phone-facing shape of a held request.
1594
1465
  const askPayload = (ask, queued) => ({
1595
1466
  id: ask.id,
@@ -1601,7 +1472,7 @@ const askPayload = (ask, queued) => ({
1601
1472
  });
1602
1473
 
1603
1474
  function readSessions() {
1604
- const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions(), ...readGrokBotSessions()];
1475
+ const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
1605
1476
  const known = new Set(sessions.map((s) => s.id));
1606
1477
  for (const s of sessions) {
1607
1478
  const queue = askQueue.get(s.id);
@@ -1640,7 +1511,20 @@ function readSessions() {
1640
1511
  }
1641
1512
 
1642
1513
  sessions.sort((a, b) => a.startedAt - b.startedAt);
1643
- return sessions;
1514
+ // One row per id, whatever the providers hand over.
1515
+ //
1516
+ // The phone keys every pilot on the session id: the crew list draws a
1517
+ // ForEach over it and the island keeps one sprite per id. Two rows sharing
1518
+ // one id do not draw twice, they draw once and leave a hole the height of a
1519
+ // card where the other should be, which reads as a layout bug rather than
1520
+ // as bad data. Codex was the source of that once already; this is here so
1521
+ // the next provider that repeats an id cannot do it again.
1522
+ const byId = new Map();
1523
+ for (const s of sessions) {
1524
+ const seen = byId.get(s.id);
1525
+ if (!seen || (s.updatedAt || 0) > (seen.updatedAt || 0)) byId.set(s.id, s);
1526
+ }
1527
+ return byId.size === sessions.length ? sessions : sessions.filter((s) => byId.get(s.id) === s);
1644
1528
  }
1645
1529
 
1646
1530
  // ---------- remote approvals: runtime ----------
@@ -1673,6 +1557,10 @@ function hostState() {
1673
1557
  settingsOk: settings !== null,
1674
1558
  socketOk: approvalServerUp,
1675
1559
  },
1560
+ // Which agents the phone may start here. An empty list is a real answer
1561
+ // (nothing launchable found); the whole field missing is an older daemon,
1562
+ // and the app hides the button rather than offering one that cannot work.
1563
+ launch: { providers: launchProviders() },
1676
1564
  updateAvailable: availableUpdate,
1677
1565
  };
1678
1566
  hostCache = { at: Date.now(), value };
@@ -1817,6 +1705,9 @@ function onPermissionRequest(req, socket) {
1817
1705
  at: Date.now(),
1818
1706
  socket,
1819
1707
  done: false,
1708
+ // Set once Claude Code has actually reported this session as waiting, so
1709
+ // releaseAnsweredOnMac knows there was a prompt on the Mac to answer.
1710
+ sawWaiting: false,
1820
1711
  };
1821
1712
  ask.holdTimer = setTimeout(
1822
1713
  () => settleAsk(ask, null, 'released', 'held too long, prompting on the Mac instead'),
@@ -1901,10 +1792,30 @@ const ANSWERED_GRACE_MS = 6000;
1901
1792
  function releaseAnsweredOnMac() {
1902
1793
  if (!pendingAsks.size) return;
1903
1794
  for (const ask of [...pendingAsks.values()]) {
1904
- if (Date.now() - ask.at < ANSWERED_GRACE_MS) continue;
1905
1795
  const status = lastRawStatus.get(ask.sessionId);
1906
1796
  // Unknown session: nothing to conclude, leave it to the socket or the cap.
1907
- if (!status || status === 'waiting') continue;
1797
+ if (!status) continue;
1798
+ // Recorded before the grace period is checked, not after: the prompt is
1799
+ // most likely to be showing as waiting in exactly those first seconds.
1800
+ if (status === 'waiting') { ask.sawWaiting = true; continue; }
1801
+ if (Date.now() - ask.at < ANSWERED_GRACE_MS) continue;
1802
+ // Not waiting is only evidence of an answer if we watched the prompt go
1803
+ // up in the first place.
1804
+ //
1805
+ // Claude Code writes "waiting" for the prompt it draws itself, and a
1806
+ // permission request raised from inside a running turn never gets one:
1807
+ // the session sits on "busy" for as long as the hook blocks. Concluding
1808
+ // from that alone declared every gated command inside a turn answered six
1809
+ // seconds after it was asked, so the card vanished off the phone mid tap
1810
+ // and the session stayed stopped on a question nobody could answer from
1811
+ // there any more.
1812
+ //
1813
+ // Idle is the signal that is safe to read instead. A session cannot go
1814
+ // idle while a hook is blocking a tool call, so idle means the turn ran
1815
+ // to the end and this request is long settled, whatever settled it. Busy
1816
+ // and shell mean the turn is still in flight, which is exactly when the
1817
+ // card has to stay put.
1818
+ if (!ask.sawWaiting && (status === 'busy' || status === 'shell')) continue;
1908
1819
  settleAsk(ask, null, 'gone', 'answered on the Mac');
1909
1820
  }
1910
1821
  }
@@ -2036,14 +1947,8 @@ function watchTranscript(sessionId) {
2036
1947
  if (!file) return;
2037
1948
  let debounce;
2038
1949
  try {
2039
- // Journals are appended to, so watching the file is enough. Grok Bot
2040
- // rewrites its replica blob wholesale, and a rename leaves a file watch
2041
- // bound to an inode nothing writes to again: watch the directory instead
2042
- // and filter, which survives the swap.
2043
- const inGrokBot = file.startsWith(GROKBOT_DIR + path.sep);
2044
- const target = inGrokBot ? GROKBOT_DIR : file;
2045
- const w = fs.watch(target, (_ev, changed) => {
2046
- if (inGrokBot && changed && changed !== path.basename(file)) return;
1950
+ // Journals are appended to, so watching the file is enough.
1951
+ const w = fs.watch(file, () => {
2047
1952
  clearTimeout(debounce);
2048
1953
  debounce = setTimeout(() => {
2049
1954
  ws?.send(seal({ type: 'transcript', sessionId, messages: readMessages(sessionId) }));
@@ -2106,6 +2011,14 @@ async function handleCommand(cmd) {
2106
2011
  settleAsk(ask, { behavior: 'deny', message: reason || 'Denied from the Ground Control app.' },
2107
2012
  'denied', reason ? `from the phone: ${reason.slice(0, 60)}` : 'from the phone');
2108
2013
  }
2014
+ } else if (cmd.type === 'projects') {
2015
+ ws?.send(seal({
2016
+ type: 'projects',
2017
+ projects: knownProjects().map((p) => ({ path: p.path, name: p.name, at: p.at })),
2018
+ providers: launchProviders(),
2019
+ }));
2020
+ } else if (cmd.type === 'launch') {
2021
+ await launchSession(cmd);
2109
2022
  } else if (cmd.type === 'ping' && cmd.sessionId) {
2110
2023
  const s = readSessions().find((x) => x.id === cmd.sessionId);
2111
2024
  ws?.send(seal({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() }));
@@ -2113,15 +2026,6 @@ async function handleCommand(cmd) {
2113
2026
  const session = readSessions().find((s) => s.id === cmd.sessionId);
2114
2027
  if (!session) return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' }));
2115
2028
 
2116
- // A Bot lives on a cloud VM this daemon has no channel to. Saying so beats
2117
- // a send that looks accepted and arrives nowhere.
2118
- if (session.provider === 'grokbot') {
2119
- return ws?.send(seal({
2120
- type: 'promptResult', sessionId: cmd.sessionId, ok: false,
2121
- error: 'Grok Bot runs in the cloud, so Ground Control can only watch it. Open the Grok Bot app to reply.',
2122
- }));
2123
- }
2124
-
2125
2029
  if (session.provider && session.provider !== 'claude') return promptHeadless(session, cmd.text);
2126
2030
 
2127
2031
  // Preferred path: type into the real terminal so the desktop updates live.
@@ -2201,7 +2105,6 @@ const headlessPromptSpec = {
2201
2105
  function bustProviderCaches() {
2202
2106
  codexCache.at = 0;
2203
2107
  kimiCache.at = 0;
2204
- grokbotCache.at = 0;
2205
2108
  }
2206
2109
 
2207
2110
  function promptHeadless(session, text) {
@@ -2258,6 +2161,166 @@ function promptHeadless(session, text) {
2258
2161
  watchTranscript(session.id);
2259
2162
  }
2260
2163
 
2164
+ // ---------- launching a new pilot ----------
2165
+ // Everything above answers a session that already exists. This starts one.
2166
+ //
2167
+ // The phone never sends a path. It picks from a list this daemon built out of
2168
+ // what is already on disk, and the launch validates the choice against that
2169
+ // same list before running anything, so the worst a stolen channel can do is
2170
+ // open a terminal in a folder you have already worked in today.
2171
+
2172
+ const PROJECT_LIMIT = 40;
2173
+ /// A folder nobody has touched in three months is not what you meant to open
2174
+ /// from your phone, and the list has to fit on a phone screen.
2175
+ const PROJECT_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
2176
+
2177
+ /// The real working directory behind one of Claude Code's project folders.
2178
+ ///
2179
+ /// The folder name is the path with every slash turned into a dash, which
2180
+ /// cannot be undone: a directory with a dash in its own name decodes to
2181
+ /// something that was never there. The transcripts inside record the actual
2182
+ /// cwd, so read it rather than guess it.
2183
+ function projectCwd(dir) {
2184
+ let files = [];
2185
+ try {
2186
+ files = fs.readdirSync(dir)
2187
+ .filter((f) => f.endsWith('.jsonl'))
2188
+ .map((f) => {
2189
+ const p = path.join(dir, f);
2190
+ try { return { p, at: fs.statSync(p).mtimeMs }; } catch { return null; }
2191
+ })
2192
+ .filter(Boolean)
2193
+ .sort((a, b) => b.at - a.at);
2194
+ } catch { return null; }
2195
+ if (!files.length) return null;
2196
+
2197
+ for (const { p, at } of files.slice(0, 3)) {
2198
+ let head = '';
2199
+ try {
2200
+ const fd = fs.openSync(p, 'r');
2201
+ const buf = Buffer.alloc(64 * 1024);
2202
+ const read = fs.readSync(fd, buf, 0, buf.length, 0);
2203
+ fs.closeSync(fd);
2204
+ head = buf.subarray(0, read).toString('utf8');
2205
+ } catch { continue; }
2206
+ for (const line of head.split('\n')) {
2207
+ if (!line.trim()) continue;
2208
+ let e; try { e = JSON.parse(line); } catch { continue; }
2209
+ if (typeof e.cwd === 'string' && e.cwd) return { cwd: e.cwd, at };
2210
+ }
2211
+ }
2212
+ return null;
2213
+ }
2214
+
2215
+ let projectsCache = { at: 0, value: [] };
2216
+ function knownProjects() {
2217
+ if (Date.now() - projectsCache.at < 60_000) return projectsCache.value;
2218
+ const byPath = new Map();
2219
+ const note = (cwd, at) => {
2220
+ if (!cwd || !fs.existsSync(cwd)) return;
2221
+ const prev = byPath.get(cwd);
2222
+ if (!prev || prev.at < at) byPath.set(cwd, { path: cwd, name: path.basename(cwd) || cwd, at });
2223
+ };
2224
+
2225
+ let dirs = [];
2226
+ try { dirs = fs.readdirSync(PROJECTS_DIR); } catch {}
2227
+ for (const d of dirs) {
2228
+ const found = projectCwd(path.join(PROJECTS_DIR, d));
2229
+ if (found) note(found.cwd, found.at);
2230
+ }
2231
+ // Whatever is flying right now, even if its transcript has not landed yet.
2232
+ for (const s of readSessions()) note(s.cwd, Date.now());
2233
+
2234
+ const cutoff = Date.now() - PROJECT_MAX_AGE_MS;
2235
+ const value = [...byPath.values()]
2236
+ .filter((p) => p.at >= cutoff)
2237
+ .sort((a, b) => b.at - a.at)
2238
+ .slice(0, PROJECT_LIMIT);
2239
+ projectsCache = { at: Date.now(), value };
2240
+ return value;
2241
+ }
2242
+
2243
+ /// Which agents this Mac can start from the phone.
2244
+ ///
2245
+ /// Claude Code only, on purpose. Every provider here has to be one whose bare
2246
+ /// command opens an interactive session that then shows up on the island, and
2247
+ /// claude is the one that has been checked. Codex and the kimi CLIs are read
2248
+ /// and written by the daemon already, but nobody has watched either of them
2249
+ /// come up this way, so offering them would be a button that might do nothing.
2250
+ function launchProviders() {
2251
+ return claudeBin() ? ['claude'] : [];
2252
+ }
2253
+
2254
+ /// Single quotes for the shell, then AppleScript quoting on top. Both layers
2255
+ /// matter: the directory comes from disk and the first order comes from your
2256
+ /// thumbs.
2257
+ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
2258
+
2259
+ let lastLaunchAt = 0;
2260
+
2261
+ async function launchSession(cmd) {
2262
+ const fail = (error) => ws?.send(seal({ type: 'launchResult', ok: false, error }));
2263
+
2264
+ const providers = launchProviders();
2265
+ const provider = cmd.provider || 'claude';
2266
+ if (!providers.includes(provider)) {
2267
+ return fail(`This Mac cannot start a ${provider} session from the phone. Ground Control could not find the ${provider} command on your PATH.`);
2268
+ }
2269
+ const target = knownProjects().find((p) => p.path === cmd.cwd);
2270
+ if (!target) {
2271
+ // The list is a minute old at most, so this is almost always a folder that
2272
+ // was renamed or deleted since it was drawn.
2273
+ return fail('That folder is not on this Mac any more. Pull the list again.');
2274
+ }
2275
+ if (Date.now() - lastLaunchAt < 5000) {
2276
+ return fail('One at a time. Give the last launch a few seconds.');
2277
+ }
2278
+
2279
+ const apps = terminalApps();
2280
+ const first = (cmd.text || '').trim();
2281
+ // Claude Code takes an opening prompt as a positional argument and starts the
2282
+ // normal interactive session with it already submitted, which beats waiting a
2283
+ // guessed number of seconds for a REPL to boot and then typing at it.
2284
+ const line = `cd ${shq(target.path)} && ${provider}${first ? ` ${shq(first)}` : ''}`;
2285
+
2286
+ lastLaunchAt = Date.now();
2287
+ let opened = null;
2288
+ if (apps.iterm) {
2289
+ const r = await osascript(`tell application "iTerm2"
2290
+ activate
2291
+ set w to (create window with default profile)
2292
+ tell current session of w to write text "${escapeAS(line)}"
2293
+ return "ok"
2294
+ end tell`);
2295
+ if (r === 'ok') opened = 'iTerm2';
2296
+ }
2297
+ if (!opened) {
2298
+ // Terminal.app is on every Mac, and telling it to do a script launches it
2299
+ // if it was closed, so this is the path that always exists.
2300
+ const r = await osascript(`tell application "Terminal"
2301
+ activate
2302
+ do script "${escapeAS(line)}"
2303
+ return "ok"
2304
+ end tell`);
2305
+ if (r === 'ok') opened = 'Terminal';
2306
+ }
2307
+
2308
+ if (!opened) {
2309
+ lastLaunchAt = 0;
2310
+ if (automationDenied) {
2311
+ 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.');
2312
+ }
2313
+ return fail('Could not open a terminal window on your Mac.');
2314
+ }
2315
+
2316
+ console.log(`[daemon] launched ${provider} in ${target.path} via ${opened}`);
2317
+ // The new session reaches the island through the same discovery every other
2318
+ // one does, which takes a few seconds. Say so rather than implying the pilot
2319
+ // is already there.
2320
+ ws?.send(seal({ type: 'launchResult', ok: true, cwd: target.path, note: `${provider} is starting in ${target.name}` }));
2321
+ sendSnapshot(true);
2322
+ }
2323
+
2261
2324
  let lastPongAt = Date.now();
2262
2325
 
2263
2326
  function connect() {
@@ -2359,4 +2422,3 @@ function watchProviderDir(dir, cache, label) {
2359
2422
  watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin() || 'not found yet, rechecked on each prompt'})`);
2360
2423
  watchProviderDir(path.join(KIMI_DIR, 'sessions'), kimiCache, 'kimi sessions');
2361
2424
  watchProviderDir(path.join(KIMICODE_DIR, 'sessions'), kimiCache, 'kimi code sessions');
2362
- watchProviderDir(GROKBOT_DIR, grokbotCache, 'grok bot roster (read only, replies happen in the Grok Bot app)');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clastres/groundcontrol",
3
- "version": "0.1.9",
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.",
3
+ "version": "0.1.11",
4
+ "description": "See and command your coding agents from your iPhone. Pairs the Ground Control app with Claude Code, Codex and Kimi sessions running on this Mac, end to end encrypted.",
5
5
  "bin": {
6
6
  "groundcontrol": "bin/groundcontrol.mjs"
7
7
  },
@@ -21,7 +21,6 @@
21
21
  "claude-code",
22
22
  "codex",
23
23
  "kimi",
24
- "grok",
25
24
  "ai",
26
25
  "agents",
27
26
  "cli",
@@ -0,0 +1,121 @@
1
+ // A held permission request must clear when it is answered on the Mac, and
2
+ // must NOT clear while it is still genuinely waiting on you. That balance has
3
+ // been wrong twice, in both directions, so it gets a test.
4
+ //
5
+ // The fast path is the hook's socket closing, and in practice it almost never
6
+ // fires: 182 of 189 settlements in a real daemon log came from this function
7
+ // instead. So the status reading is the mechanism, not the safety net.
8
+ //
9
+ // Wrong the first way: any status other than "waiting" counted as answered.
10
+ // Claude Code only writes "waiting" for the prompt it draws itself, and a
11
+ // permission request raised from inside a running turn never gets one, so
12
+ // every gated command inside a turn was declared answered six seconds after
13
+ // it was asked. The card vanished off the phone mid tap and the session
14
+ // stayed stopped on a question nobody could answer from there.
15
+ //
16
+ // Wrong the second way: requiring "waiting" to have been seen at all. That is
17
+ // correct and it never lets go, so a request genuinely answered on the Mac
18
+ // leaves a phantom card until the twelve hour hold cap.
19
+ //
20
+ // Right: hold through busy and shell, let go on idle. A session cannot report
21
+ // idle while a hook is blocking a tool call.
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
27
+ const src = fs.readFileSync(DAEMON, 'utf8');
28
+ const start = src.indexOf('const ANSWERED_GRACE_MS');
29
+ const end = src.indexOf('/// Sitting back down at the Mac clears the phone card.');
30
+ if (start < 0 || end < 0 || end < start) throw new Error('could not locate releaseAnsweredOnMac in daemon.mjs');
31
+
32
+ let failures = 0;
33
+ const check = (label, cond, detail = '') => {
34
+ console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
35
+ if (!cond) failures++;
36
+ };
37
+
38
+ const settled = [];
39
+ const pendingAsks = new Map();
40
+ const lastRawStatus = new Map();
41
+ const settleAsk = (ask, _decision, outcome, note) => {
42
+ pendingAsks.delete(ask.id);
43
+ settled.push({ id: ask.id, outcome, note });
44
+ };
45
+
46
+ const { releaseAnsweredOnMac, ANSWERED_GRACE_MS } = new Function(
47
+ 'pendingAsks', 'lastRawStatus', 'settleAsk',
48
+ `${src.slice(start, end)}\n return { releaseAnsweredOnMac, ANSWERED_GRACE_MS };`,
49
+ )(pendingAsks, lastRawStatus, settleAsk);
50
+
51
+ /// A held request, aged so the grace period is behind it unless asked otherwise.
52
+ function hold(id, sessionId, { ageMs = ANSWERED_GRACE_MS + 1000, sawWaiting = false } = {}) {
53
+ const ask = { id, sessionId, at: Date.now() - ageMs, sawWaiting };
54
+ pendingAsks.set(id, ask);
55
+ return ask;
56
+ }
57
+ const reset = () => { pendingAsks.clear(); lastRawStatus.clear(); settled.length = 0; };
58
+ const outcomeOf = (id) => settled.find((s) => s.id === id)?.outcome;
59
+
60
+ // The bug Carlos hit: a gated command inside a running turn. Claude never says
61
+ // waiting, it says busy for as long as the hook blocks.
62
+ reset();
63
+ hold('a1', 's-busy');
64
+ lastRawStatus.set('s-busy', 'busy');
65
+ releaseAnsweredOnMac();
66
+ check('a request held inside a running turn is not declared answered', pendingAsks.has('a1'), outcomeOf('a1') || 'still held');
67
+
68
+ // Same, for a session running a shell command.
69
+ reset();
70
+ hold('a2', 's-shell');
71
+ lastRawStatus.set('s-shell', 'shell');
72
+ releaseAnsweredOnMac();
73
+ check('nor one held while a shell command runs', pendingAsks.has('a2'));
74
+
75
+ // The other failure mode: the turn ended, so whatever was holding it is gone.
76
+ reset();
77
+ hold('a3', 's-idle');
78
+ lastRawStatus.set('s-idle', 'idle');
79
+ releaseAnsweredOnMac();
80
+ check('an idle session releases the request', outcomeOf('a3') === 'gone', outcomeOf('a3') || 'still held');
81
+
82
+ // A busy session that later goes idle has to release, or the card is phantom
83
+ // until the twelve hour cap.
84
+ reset();
85
+ const a4 = hold('a4', 's-turn');
86
+ lastRawStatus.set('s-turn', 'busy');
87
+ releaseAnsweredOnMac();
88
+ const heldDuringTurn = pendingAsks.has('a4');
89
+ lastRawStatus.set('s-turn', 'idle');
90
+ releaseAnsweredOnMac();
91
+ check('held through the turn, released when it ends', heldDuringTurn && outcomeOf('a4') === 'gone');
92
+ check('the ask is not left in the pending map', !pendingAsks.has('a4') && a4.id === 'a4');
93
+
94
+ // Claude's own prompt does write waiting, and dropping out of it is the
95
+ // original signal, which still has to work.
96
+ reset();
97
+ hold('a5', 's-prompt');
98
+ lastRawStatus.set('s-prompt', 'waiting');
99
+ releaseAnsweredOnMac();
100
+ const heldWhileWaiting = pendingAsks.has('a5');
101
+ lastRawStatus.set('s-prompt', 'busy');
102
+ releaseAnsweredOnMac();
103
+ check('a prompt seen waiting releases as soon as it stops waiting',
104
+ heldWhileWaiting && outcomeOf('a5') === 'gone', outcomeOf('a5') || 'still held');
105
+
106
+ // The grace period covers the gap between the hook firing and Claude Code
107
+ // getting round to writing its status.
108
+ reset();
109
+ hold('a6', 's-fresh', { ageMs: 1000 });
110
+ lastRawStatus.set('s-fresh', 'idle');
111
+ releaseAnsweredOnMac();
112
+ check('a request younger than the grace period is left alone', pendingAsks.has('a6'));
113
+
114
+ // A session the daemon cannot see at all is not evidence of anything.
115
+ reset();
116
+ hold('a7', 's-unknown');
117
+ releaseAnsweredOnMac();
118
+ check('an unknown session releases nothing', pendingAsks.has('a7'));
119
+
120
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
121
+ process.exit(failures ? 1 : 0);
package/test/codex.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // Codex writes one rollout file per run, and two of the ways it does that used
2
+ // to put holes in the app.
3
+ //
4
+ // A helper thread (the review subagent) gets its own rollout carrying the
5
+ // *parent's* session_id, so the daemon reported two sessions under one id. The
6
+ // phone keys every pilot on that id, so it drew one and left a card sized gap
7
+ // where the other should have been, on the crew list and on the island both.
8
+ //
9
+ // And a plain `codex exec` starts a fresh rollout every time, so asking Codex
10
+ // to review something three times in one folder put three identical pilots on
11
+ // the island. Only the freshest one in a directory is worth talking to.
12
+ //
13
+ // This runs the real reader out of daemon.mjs against a synthetic ~/.codex.
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
20
+ const src = fs.readFileSync(DAEMON, 'utf8');
21
+ const start = src.indexOf('// ---------- Codex session discovery ----------');
22
+ const end = src.indexOf('// ---------- Kimi session discovery ----------');
23
+ if (start < 0 || end < 0 || end < start) throw new Error('could not locate the Codex block in daemon.mjs');
24
+
25
+ let failures = 0;
26
+ const check = (label, cond, detail = '') => {
27
+ console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
28
+ if (!cond) failures++;
29
+ };
30
+
31
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-codex-'));
32
+ const day = path.join(tmp, 'sessions', '2026', '09', '05');
33
+ fs.mkdirSync(day, { recursive: true });
34
+
35
+ /// One rollout: the session_meta line, then a finished turn so the reader has
36
+ /// something to call a last message.
37
+ function rollout(name, meta, { text = 'hello', minutesAgo = 1 } = {}) {
38
+ const file = path.join(day, `rollout-${name}.jsonl`);
39
+ const at = new Date(Date.now() - minutesAgo * 60_000).toISOString();
40
+ fs.writeFileSync(file, [
41
+ JSON.stringify({ timestamp: at, type: 'session_meta', payload: { timestamp: at, ...meta } }),
42
+ JSON.stringify({ timestamp: at, type: 'event_msg', payload: { type: 'task_started' } }),
43
+ JSON.stringify({ timestamp: at, type: 'event_msg', payload: { type: 'agent_message', message: text } }),
44
+ JSON.stringify({ timestamp: at, type: 'event_msg', payload: { type: 'task_complete' } }),
45
+ '',
46
+ ].join('\n'));
47
+ const when = Date.now() - minutesAgo * 60_000;
48
+ fs.utimesSync(file, when / 1000, when / 1000);
49
+ return file;
50
+ }
51
+
52
+ const PROJECT = '/tmp/project-one';
53
+ const OTHER = '/tmp/project-two';
54
+
55
+ // The pair that broke it: a run and the review helper it spawned, same second,
56
+ // same id, two files.
57
+ rollout('a-parent', { session_id: 'id-parent', id: 'id-parent', cwd: PROJECT, thread_source: 'user', source: 'exec' },
58
+ { text: 'the older run', minutesAgo: 9 });
59
+ rollout('a-helper', { session_id: 'id-parent', id: 'id-child', parent_thread_id: 'id-parent', cwd: PROJECT, thread_source: 'subagent', source: { subagent: 'review' } },
60
+ { text: 'reviewing', minutesAgo: 8 });
61
+ // A second, unrelated run in the same folder, more recent than both.
62
+ rollout('b-newer', { session_id: 'id-newer', id: 'id-newer', cwd: PROJECT, thread_source: 'user', source: 'exec' },
63
+ { text: 'the newest run', minutesAgo: 2 });
64
+ // A different folder, which must survive on its own.
65
+ rollout('c-other', { session_id: 'id-other', id: 'id-other', cwd: OTHER, thread_source: 'user', source: 'exec' },
66
+ { text: 'elsewhere', minutesAgo: 3 });
67
+ // Older than the active window: gone from the fleet entirely.
68
+ rollout('d-stale', { session_id: 'id-stale', id: 'id-stale', cwd: '/tmp/project-three', thread_source: 'user', source: 'exec' },
69
+ { text: 'yesterday', minutesAgo: 60 * 5 });
70
+
71
+ // tailLines, memoScan and scanMemo all live inside the extracted block, so the
72
+ // only things handed in are the modules and the two maps the daemon shares.
73
+ const block = src.slice(start, end)
74
+ .replace(/const CODEX_DIR = .*/, `const CODEX_DIR = ${JSON.stringify(tmp)};`);
75
+
76
+ const { readCodexSessions } = new Function('fs', 'os', 'path', 'transcriptCache', 'headlessActive',
77
+ `${block}\n return { readCodexSessions };`)(fs, os, path, new Map(), new Set());
78
+
79
+ const sessions = readCodexSessions();
80
+ const ids = sessions.map((s) => s.id);
81
+ const cwds = sessions.map((s) => s.cwd);
82
+
83
+ check('the helper thread is not a pilot', !ids.includes('id-child') && ids.filter((i) => i === 'id-parent').length <= 1, ids.join(', '));
84
+ check('no id appears twice', new Set(ids).size === ids.length, ids.join(', '));
85
+ check('one pilot per folder', new Set(cwds).size === cwds.length, cwds.join(', '));
86
+ check('the folder keeps its freshest run', ids.includes('id-newer') && !ids.includes('id-parent'), ids.join(', '));
87
+ check('a second folder still flies', ids.includes('id-other'));
88
+ check('a stale rollout is off the island', !ids.includes('id-stale'));
89
+ check('the surviving pilot carries its own last message',
90
+ sessions.find((s) => s.id === 'id-newer')?.lastMessage?.text === 'the newest run');
91
+
92
+ fs.rmSync(tmp, { recursive: true, force: true });
93
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
94
+ process.exit(failures ? 1 : 0);
@@ -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);
@@ -20,11 +20,14 @@ const check = (label, cond, detail = '') => {
20
20
  if (!cond) failures++;
21
21
  };
22
22
 
23
- // Find every `sessions.push({ ... })` and read the object literal that follows,
24
- // counting braces so a nested object cannot end the block early.
23
+ // Find every session object literal and read it, counting braces so a nested
24
+ // object cannot end the block early. Most readers push one straight onto the
25
+ // list; codex builds one first, because it keeps only the freshest rollout per
26
+ // folder and may have to replace a row it already added.
25
27
  const blocks = [];
26
28
  lines.forEach((line, i) => {
27
- if (!/sessions\.push\(\{\s*$/.test(line.trim())) return;
29
+ const t = line.trim();
30
+ if (!/sessions\.push\(\{\s*$/.test(t) && !/^const session = \{\s*$/.test(t)) return;
28
31
  let depth = 1;
29
32
  const body = [];
30
33
  for (let j = i + 1; j < lines.length && depth > 0; j++) {
@@ -48,7 +51,7 @@ for (const b of blocks) {
48
51
  // heard of still renders, uppercased, but a rename would silently drop a brand
49
52
  // name that used to be there, so the known set is pinned.
50
53
  const providers = new Set(blocks.map((b) => (b.body.match(/provider:\s*'([^']+)'/) || [])[1]).filter(Boolean));
51
- for (const known of ['claude', 'codex', 'kimi', 'kimicode', 'grokbot']) {
54
+ for (const known of ['claude', 'codex', 'kimi', 'kimicode']) {
52
55
  check(`${known} is still a provider the daemon emits`, providers.has(known));
53
56
  }
54
57
 
package/test/grokbot.mjs DELETED
@@ -1,86 +0,0 @@
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);