@clastres/groundcontrol 0.1.7 → 0.1.9

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
@@ -15,14 +15,14 @@ import net from 'node:net';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
17
  import readline from 'node:readline';
18
- import { spawn, execFile, execSync } from 'node:child_process';
18
+ import { spawn, spawnSync, execFile, execSync } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  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.9';
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;
@@ -1214,14 +1232,38 @@ function readKimiLegacySessions() {
1214
1232
  // snapshot interval, where an exception would crash-loop the whole daemon.
1215
1233
  const isoOf = (t) => { try { return t ? new Date(t).toISOString() : null; } catch { return null; } };
1216
1234
 
1217
- // Kimi Code journal: turn.prompt opens a turn; a text-only assistant message
1218
- // (no tool calls), a turn.cancel, or an Interrupt closes it.
1235
+ // Kimi Code journal: turn.prompt opens a turn; turn.ended, turn.cancel, an
1236
+ // Interrupt, or a text-only assistant message closes it.
1237
+ //
1238
+ // The reply itself needs two readers. Current Kimi Code writes the assistant's
1239
+ // answer only as content parts on the loop event stream and never writes an
1240
+ // assistant context.append_message at all, so a reader waiting for one shows
1241
+ // your question and never the answer, and leaves the session marked busy until
1242
+ // the staleness timeout gives up. Older builds do write that message, so both
1243
+ // paths stay, with a dedupe for any build that writes both.
1219
1244
  function kimiCodeScan(wireFile, maxBytes = 512 * 1024, limit = 80) {
1220
1245
  let lines;
1221
1246
  try { lines = tailLines(wireFile, maxBytes); } catch { return { messages: [], busy: false, sawMarkers: true, waiting: false }; }
1222
1247
  const messages = [];
1223
1248
  let lastPrompt = 0, lastEnd = 0;
1224
1249
  const pendingInteractions = new Set();
1250
+
1251
+ // A step can emit its text in several parts. Those are one reply, so they
1252
+ // join rather than arriving as a burst of one line messages. Parts join raw:
1253
+ // trimming each one first would weld "Connection " to "looks good." with no
1254
+ // space between them. The seams are trimmed once, at the end.
1255
+ let openStep = null;
1256
+ const pushAssistant = (text, ts, stepUuid) => {
1257
+ const last = messages[messages.length - 1];
1258
+ if (stepUuid && openStep === stepUuid && last?.role === 'assistant') {
1259
+ last.text = `${last.text}${text}`.slice(0, 4000);
1260
+ return;
1261
+ }
1262
+ if (last?.role === 'assistant' && last.text.trim() === text.trim()) return;
1263
+ messages.push({ role: 'assistant', text: text.slice(0, 4000), ts });
1264
+ openStep = stepUuid || null;
1265
+ };
1266
+
1225
1267
  for (const line of lines) {
1226
1268
  if (!line.trim()) continue;
1227
1269
  let e; try { e = JSON.parse(line); } catch { continue; }
@@ -1232,12 +1274,23 @@ function kimiCodeScan(wireFile, maxBytes = 512 * 1024, limit = 80) {
1232
1274
  if (text) messages.push({ role: 'user', text: text.slice(0, 4000), ts });
1233
1275
  } else if (e.type === 'turn.cancel') {
1234
1276
  lastEnd = Math.max(lastEnd, e.time || 0, lastPrompt);
1277
+ } else if (e.type === 'turn.ended') {
1278
+ lastEnd = Math.max(lastEnd, e.time || 0);
1279
+ } else if (e.type === 'context.append_loop_event') {
1280
+ const ev = e.event || {};
1281
+ const part = ev.part || {};
1282
+ // 'think' parts are reasoning, which belongs on the Mac and not in a
1283
+ // phone transcript.
1284
+ if (ev.type === 'content.part' && part.type === 'text') {
1285
+ const text = typeof part.text === 'string' ? part.text : '';
1286
+ if (text.trim()) pushAssistant(text, ts, ev.stepUuid);
1287
+ }
1235
1288
  } else if (e.type === 'context.append_message') {
1236
1289
  const m = e.message || {};
1237
1290
  const text = kosongText(m.content).trim();
1238
1291
  const toolCalls = m.toolCalls || m.tool_calls;
1239
1292
  if (m.role === 'assistant' && !(toolCalls && toolCalls.length)) {
1240
- if (text) messages.push({ role: 'assistant', text: text.slice(0, 4000), ts });
1293
+ if (text) pushAssistant(text, ts, null);
1241
1294
  lastEnd = Math.max(lastEnd, e.time || 0);
1242
1295
  }
1243
1296
  } else if (e.type === 'interaction.request') {
@@ -1249,7 +1302,7 @@ function kimiCodeScan(wireFile, maxBytes = 512 * 1024, limit = 80) {
1249
1302
  }
1250
1303
  }
1251
1304
  return {
1252
- messages: messages.slice(-limit),
1305
+ messages: messages.slice(-limit).map((m) => ({ ...m, text: m.text.trim() })),
1253
1306
  busy: lastPrompt > lastEnd,
1254
1307
  // Same tail-window caveat as Codex: no markers seen means a long turn
1255
1308
  // pushed them out, so freshness decides.
@@ -1258,6 +1311,49 @@ function kimiCodeScan(wireFile, maxBytes = 512 * 1024, limit = 80) {
1258
1311
  };
1259
1312
  }
1260
1313
 
1314
+ // Kimi Code records nothing that says a session ended: no pid in state.json, no
1315
+ // ended flag, and `lastTurnReason: "completed"` reads the same whether you
1316
+ // answered and walked away or quit the CLI outright. An idle session holds no
1317
+ // file open either, so freshness alone kept a session you killed on the island
1318
+ // for the full two hour window as a pilot nobody was home for.
1319
+ //
1320
+ // What the system does know is how many kimi processes are running and where.
1321
+ // A directory with one live process has one live session, whatever its journals
1322
+ // say, so sessions are ranked by recency inside their directory and the newest
1323
+ // N survive.
1324
+ //
1325
+ // It is a heuristic, and it is wrong in exactly one direction: two sessions open
1326
+ // in the same directory with one of them killed keeps whichever spoke last
1327
+ // rather than whichever is actually alive. Every other case is right, and a
1328
+ // directory nobody is working in empties out, which is the case that made the
1329
+ // island lie. When lsof cannot answer, nothing is pruned.
1330
+ //
1331
+ // `-Fn` has to be one token: this lsof (4.91, the one Apple ships) reads `-F n`
1332
+ // as an empty field list followed by a file named "n".
1333
+ let kimiProcCache = { at: 0, counts: null };
1334
+ function liveKimiCwdCounts() {
1335
+ if (Date.now() - kimiProcCache.at < 5_000) return kimiProcCache.counts;
1336
+ kimiProcCache.at = Date.now();
1337
+ const r = spawnSync('lsof', ['-c', 'kimi', '-a', '-d', 'cwd', '-Fn'], { encoding: 'utf8', timeout: 4000 });
1338
+ // status 1 is "no matching process", which is a real answer: nobody is
1339
+ // running kimi, so no session in any directory is live.
1340
+ if (r.error || r.status === null || r.status > 1) {
1341
+ kimiProcCache.counts = null;
1342
+ return null;
1343
+ }
1344
+ const counts = new Map();
1345
+ for (const line of (r.stdout || '').split('\n')) {
1346
+ if (!line.startsWith('n')) continue;
1347
+ counts.set(line.slice(1), (counts.get(line.slice(1)) || 0) + 1);
1348
+ }
1349
+ kimiProcCache.counts = counts;
1350
+ return counts;
1351
+ }
1352
+
1353
+ /// lsof reports a resolved cwd, so a session whose recorded cwd runs through a
1354
+ /// symlink has to be resolved the same way before the two can be compared.
1355
+ const realpathOf = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
1356
+
1261
1357
  function readKimiCodeMessages(file, maxBytes = 512 * 1024, limit = 80) {
1262
1358
  return kimiCodeScan(file, maxBytes, limit).messages;
1263
1359
  }
@@ -1267,6 +1363,11 @@ function readKimiCodeSessions() {
1267
1363
  const root = path.join(KIMICODE_DIR, 'sessions');
1268
1364
  let wdKeys = [];
1269
1365
  try { wdKeys = fs.readdirSync(root); } catch { return sessions; }
1366
+
1367
+ // Two passes: gather the candidates inside the activity window, ask lsof
1368
+ // about all of them at once, then decide. One process table walk per scan
1369
+ // rather than one per session.
1370
+ const candidates = [];
1270
1371
  for (const wdKey of wdKeys) {
1271
1372
  let ids = [];
1272
1373
  try { ids = fs.readdirSync(path.join(root, wdKey)); } catch { continue; }
@@ -1280,27 +1381,53 @@ function readKimiCodeSessions() {
1280
1381
  let mtime = state.updatedAt || 0;
1281
1382
  try { mtime = Math.max(mtime, fs.statSync(wire).mtimeMs); } catch {}
1282
1383
  if (Date.now() - mtime > KIMI_ACTIVE_WINDOW_MS) continue;
1283
- const scan = memoScan(`kimicode:${wire}`, wire, () => kimiCodeScan(wire, 64 * 1024, 1));
1284
- const busy = (scan.busy || (!scan.sawMarkers && Date.now() - mtime < 60_000))
1285
- && Date.now() - mtime < KIMI_BUSY_STALE_MS;
1286
- // A pending approval outranks busy: the agent is stopped on a human.
1287
- const status = scan.waiting ? 'waiting' : (headlessActive.has(state.id) || busy ? 'busy' : 'idle');
1288
- transcriptCache.set(state.id, wire);
1289
- sessions.push({
1290
- id: state.id,
1291
- provider: 'kimicode',
1292
- name: state.title || (state.lastPrompt || '').slice(0, 40) || path.basename(state.cwd || '?'),
1293
- cwd: state.cwd || '',
1294
- project: path.basename(state.cwd || '?'),
1295
- status,
1296
- updatedAt: mtime,
1297
- startedAt: state.createdAt || 0,
1298
- lastMessage: scan.messages.length ? scan.messages[scan.messages.length - 1] : null,
1299
- tty: '',
1300
- live: false,
1301
- });
1384
+ candidates.push({ state, wire, mtime });
1385
+ }
1386
+ }
1387
+
1388
+ // Keep the newest N in each directory, N being the number of kimi processes
1389
+ // working there. A null count means lsof could not answer, so nothing is
1390
+ // pruned and this behaves as it did before.
1391
+ const counts = liveKimiCwdCounts();
1392
+ let keep = candidates;
1393
+ if (counts) {
1394
+ const byDir = new Map();
1395
+ for (const c of candidates) {
1396
+ const dir = c.state.cwd ? realpathOf(c.state.cwd) : '';
1397
+ if (!byDir.has(dir)) byDir.set(dir, []);
1398
+ byDir.get(dir).push(c);
1399
+ }
1400
+ keep = [];
1401
+ for (const [dir, group] of byDir) {
1402
+ // A session with no recorded directory cannot be matched to a process,
1403
+ // and hiding a session on a guess is worse than showing a stale one.
1404
+ if (!dir) { keep.push(...group); continue; }
1405
+ group.sort((a, b) => b.mtime - a.mtime);
1406
+ keep.push(...group.slice(0, counts.get(dir) || 0));
1302
1407
  }
1303
1408
  }
1409
+
1410
+ for (const { state, wire, mtime } of keep) {
1411
+ const scan = memoScan(`kimicode:${wire}`, wire, () => kimiCodeScan(wire, 64 * 1024, 1));
1412
+ const busy = (scan.busy || (!scan.sawMarkers && Date.now() - mtime < 60_000))
1413
+ && Date.now() - mtime < KIMI_BUSY_STALE_MS;
1414
+ // A pending approval outranks busy: the agent is stopped on a human.
1415
+ const status = scan.waiting ? 'waiting' : (headlessActive.has(state.id) || busy ? 'busy' : 'idle');
1416
+ transcriptCache.set(state.id, wire);
1417
+ sessions.push({
1418
+ id: state.id,
1419
+ provider: 'kimicode',
1420
+ name: state.title || (state.lastPrompt || '').slice(0, 40) || path.basename(state.cwd || '?'),
1421
+ cwd: state.cwd || '',
1422
+ project: path.basename(state.cwd || '?'),
1423
+ status,
1424
+ updatedAt: mtime,
1425
+ startedAt: state.createdAt || 0,
1426
+ lastMessage: scan.messages.length ? scan.messages[scan.messages.length - 1] : null,
1427
+ tty: '',
1428
+ live: false,
1429
+ });
1430
+ }
1304
1431
  return sessions;
1305
1432
  }
1306
1433
 
@@ -1312,6 +1439,157 @@ function readKimiSessions() {
1312
1439
  return kimiCache.sessions;
1313
1440
  }
1314
1441
 
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
+
1315
1593
  /// The phone-facing shape of a held request.
1316
1594
  const askPayload = (ask, queued) => ({
1317
1595
  id: ask.id,
@@ -1323,7 +1601,7 @@ const askPayload = (ask, queued) => ({
1323
1601
  });
1324
1602
 
1325
1603
  function readSessions() {
1326
- const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
1604
+ const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions(), ...readGrokBotSessions()];
1327
1605
  const known = new Set(sessions.map((s) => s.id));
1328
1606
  for (const s of sessions) {
1329
1607
  const queue = askQueue.get(s.id);
@@ -1758,7 +2036,14 @@ function watchTranscript(sessionId) {
1758
2036
  if (!file) return;
1759
2037
  let debounce;
1760
2038
  try {
1761
- const w = fs.watch(file, () => {
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;
1762
2047
  clearTimeout(debounce);
1763
2048
  debounce = setTimeout(() => {
1764
2049
  ws?.send(seal({ type: 'transcript', sessionId, messages: readMessages(sessionId) }));
@@ -1828,6 +2113,15 @@ async function handleCommand(cmd) {
1828
2113
  const session = readSessions().find((s) => s.id === cmd.sessionId);
1829
2114
  if (!session) return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' }));
1830
2115
 
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
+
1831
2125
  if (session.provider && session.provider !== 'claude') return promptHeadless(session, cmd.text);
1832
2126
 
1833
2127
  // Preferred path: type into the real terminal so the desktop updates live.
@@ -1849,7 +2143,8 @@ async function handleCommand(cmd) {
1849
2143
  }
1850
2144
  }
1851
2145
  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) {
2146
+ const bin = claudeBin();
2147
+ if (!bin) {
1853
2148
  console.log('[daemon] prompt failed: claude binary not found on PATH');
1854
2149
  return ws?.send(seal({
1855
2150
  type: 'promptResult', sessionId: cmd.sessionId, ok: false,
@@ -1859,7 +2154,7 @@ async function handleCommand(cmd) {
1859
2154
  console.log(`[daemon] prompt -> ${session.name}: ${cmd.text.slice(0, 60)}`);
1860
2155
  headlessActive.add(cmd.sessionId);
1861
2156
  sendSnapshot(true);
1862
- const child = spawn(claudeBin, ['--resume', cmd.sessionId, '-p', cmd.text], {
2157
+ const child = spawn(bin, ['--resume', cmd.sessionId, '-p', cmd.text], {
1863
2158
  cwd: fs.existsSync(session.cwd || '') ? session.cwd : os.homedir(),
1864
2159
  env: process.env,
1865
2160
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -1898,14 +2193,15 @@ async function handleCommand(cmd) {
1898
2193
  // dash there (the models do not care about leading whitespace).
1899
2194
  const defuse = (text) => (/^\s*-/.test(text) ? ` ${text}` : text);
1900
2195
  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)] },
2196
+ codex: () => { const bin = codexBin(); return bin && { bin, args: (id, text) => ['exec', 'resume', id, '--skip-git-repo-check', '--', text] }; },
2197
+ kimicode: () => { const bin = kimiCodeBin(); return bin && { bin, args: (id, text) => ['--session', id, '-p', defuse(text)] }; },
2198
+ kimi: () => { const bin = kimiLegacyBin(); return bin && { bin, args: (id, text) => ['--print', '--session', id, '-p', defuse(text)] }; },
1904
2199
  };
1905
2200
 
1906
2201
  function bustProviderCaches() {
1907
2202
  codexCache.at = 0;
1908
2203
  kimiCache.at = 0;
2204
+ grokbotCache.at = 0;
1909
2205
  }
1910
2206
 
1911
2207
  function promptHeadless(session, text) {
@@ -1918,7 +2214,7 @@ function promptHeadless(session, text) {
1918
2214
  console.log(`[daemon] ${session.provider} prompt failed: binary not found`);
1919
2215
  return ws?.send(seal({
1920
2216
  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.`,
2217
+ 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
2218
  }));
1923
2219
  }
1924
2220
  console.log(`[daemon] prompt -> ${session.provider} ${session.name}: ${text.slice(0, 60)}`);
@@ -2060,6 +2356,7 @@ function watchProviderDir(dir, cache, label) {
2060
2356
  console.log(`[daemon] ${label}: watching ${dir}`);
2061
2357
  } catch {}
2062
2358
  }
2063
- watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin || 'not found, sessions will be read only'})`);
2359
+ watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin() || 'not found yet, rechecked on each prompt'})`);
2064
2360
  watchProviderDir(path.join(KIMI_DIR, 'sessions'), kimiCache, 'kimi sessions');
2065
2361
  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,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.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.",
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);
@@ -0,0 +1,78 @@
1
+ // Kimi Code records nothing on disk that says a session is over. There is no
2
+ // pid, no ended flag, and `lastTurnReason: "completed"` looks identical whether
3
+ // you answered and walked away or quit the CLI outright. Presence came from
4
+ // journal freshness alone, so a session you killed stayed on the island for the
5
+ // full two hour window as a pilot nobody was home for.
6
+ //
7
+ // An open handle on the journal looked like the answer and was not: that turned
8
+ // out to be a writer mid turn, and an idle session holds nothing at all. What is
9
+ // left is process count per directory, which is what this checks.
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
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('// Kimi Code records nothing that says a session ended');
19
+ const end = src.indexOf('function readKimiCodeMessages');
20
+ if (start < 0 || end < 0 || end < start) throw new Error('could not locate the liveness block in daemon.mjs');
21
+
22
+ // fs matters as much as spawnSync: the resolver realpaths every directory
23
+ // before comparing, and without it the symlink case silently degrades.
24
+ const load = (spawn) => new Function('fs', 'spawnSync', 'Date',
25
+ `${src.slice(start, end)}\n return { liveKimiCwdCounts, realpathOf };`)(fs, spawn, Date);
26
+
27
+ let failures = 0;
28
+ const check = (label, cond, detail = '') => {
29
+ console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
30
+ if (!cond) failures++;
31
+ };
32
+
33
+ // Two processes in one directory, one in another, in lsof's -F output shape.
34
+ const fakeLsof = (stdout, status = 0) => () => ({ status, stdout, error: null });
35
+ let { liveKimiCwdCounts } = load(fakeLsof('p101\nfcwd\nn/Users/x/one\np102\nfcwd\nn/Users/x/one\np103\nfcwd\nn/Users/x/two\n'));
36
+ let counts = liveKimiCwdCounts();
37
+ check('two processes in a directory count as two', counts.get('/Users/x/one') === 2);
38
+ check('one process elsewhere counts as one', counts.get('/Users/x/two') === 1);
39
+ check('a directory with no process is absent', counts.get('/Users/x/three') === undefined);
40
+
41
+ // lsof exits 1 when no process matches. That is an answer, not a failure: it
42
+ // means nobody is running kimi, so nothing should survive.
43
+ ({ liveKimiCwdCounts } = load(fakeLsof('', 1)));
44
+ counts = liveKimiCwdCounts();
45
+ check('no kimi running is a real answer, not unknown', counts instanceof Map && counts.size === 0);
46
+
47
+ // A missing or broken lsof must not prune anything.
48
+ ({ liveKimiCwdCounts } = load(() => ({ status: null, stdout: '', error: new Error('ENOENT') })));
49
+ check('a missing lsof reports unknown', liveKimiCwdCounts() === null);
50
+ ({ liveKimiCwdCounts } = load(fakeLsof('garbage', 9)));
51
+ check('a failing lsof reports unknown', liveKimiCwdCounts() === null);
52
+
53
+ // realpathOf has to resolve, because lsof answers with the resolved path and
54
+ // macOS puts every temp directory behind a symlink.
55
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-live-'));
56
+ const target = path.join(tmp, 'work');
57
+ const link = path.join(tmp, 'link');
58
+ fs.mkdirSync(target);
59
+ fs.symlinkSync(target, link);
60
+ const { realpathOf } = load(fakeLsof(''));
61
+ check('a symlinked directory resolves to its target', realpathOf(link) === fs.realpathSync(target));
62
+ check('a path that does not exist is returned unchanged', realpathOf('/nope/not/here') === '/nope/not/here');
63
+ fs.rmSync(tmp, { recursive: true, force: true });
64
+
65
+ // Against this Mac, with the real lsof.
66
+ const { liveKimiCwdCounts: real } = load(spawnSync);
67
+ const live = real();
68
+ if (live === null) {
69
+ console.log('\nlsof could not answer on this Mac, so nothing would be pruned');
70
+ } else if (live.size === 0) {
71
+ console.log('\nno kimi processes running: every Kimi session would leave the island');
72
+ } else {
73
+ console.log(`\nlive kimi working directories:`);
74
+ for (const [dir, n] of live) console.log(` ${n} process(es) ${dir}`);
75
+ }
76
+
77
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
78
+ process.exit(failures ? 1 : 0);
package/test/kimi.mjs ADDED
@@ -0,0 +1,138 @@
1
+ // Kimi Code writes the user's turn as `turn.prompt` but the assistant's reply
2
+ // only as `content.part` loop events, with no assistant `context.append_message`
3
+ // anywhere in the journal. A reader that waits for that message shows your
4
+ // question and never the answer, and never sees the turn close, so the session
5
+ // also stays busy until the staleness timeout gives up.
6
+ //
7
+ // This runs the real kimiCodeScan out of daemon.mjs against a synthetic journal
8
+ // in both shapes, then against whatever real sessions this Mac has.
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
15
+ const src = fs.readFileSync(DAEMON, 'utf8');
16
+ const start = src.indexOf('// Kimi Code journal: turn.prompt opens a turn');
17
+ const end = src.indexOf('function readKimiCodeMessages');
18
+ if (start < 0 || end < 0 || end < start) throw new Error('could not locate kimiCodeScan in daemon.mjs');
19
+
20
+ const tailLines = (file, maxBytes) => {
21
+ const size = fs.statSync(file).size;
22
+ const fd = fs.openSync(file, 'r');
23
+ const startAt = Math.max(0, size - maxBytes);
24
+ const buf = Buffer.alloc(size - startAt);
25
+ fs.readSync(fd, buf, 0, buf.length, startAt);
26
+ fs.closeSync(fd);
27
+ return buf.toString('utf8').split('\n');
28
+ };
29
+ const isoOf = (t) => { try { return t ? new Date(t).toISOString() : null; } catch { return null; } };
30
+ const kosongText = (content) => {
31
+ if (typeof content === 'string') return content;
32
+ if (Array.isArray(content)) return content.filter((p) => p && (p.type === 'text' || p.text)).map((p) => p.text || '').join('\n');
33
+ return '';
34
+ };
35
+
36
+ const { kimiCodeScan } = new Function('fs', 'tailLines', 'isoOf', 'kosongText',
37
+ `${src.slice(start, end)}\n return { kimiCodeScan };`)(fs, tailLines, isoOf, kosongText);
38
+
39
+ let failures = 0;
40
+ const check = (label, cond, detail = '') => {
41
+ console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
42
+ if (!cond) failures++;
43
+ };
44
+
45
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-kimi-'));
46
+ const write = (name, records) => {
47
+ const p = path.join(tmp, name);
48
+ fs.writeFileSync(p, records.map((r) => JSON.stringify(r)).join('\n'));
49
+ return p;
50
+ };
51
+
52
+ // The shape Kimi Code actually writes today, taken from a live journal.
53
+ const loopEvents = write('loop.jsonl', [
54
+ { type: 'turn.prompt', time: 1000, input: [{ type: 'text', text: 'are you there?' }] },
55
+ // Injected context arrives as a user append_message and is not a typed turn.
56
+ { type: 'context.append_message', time: 1001, message: { role: 'user', content: [{ type: 'text', text: '<system-reminder>\nAuto permission mode is active.' }] } },
57
+ { type: 'context.append_loop_event', time: 1002, event: { type: 'content.part', stepUuid: 's1', part: { type: 'think', think: 'a simple check in' } } },
58
+ { type: 'context.append_loop_event', time: 1003, event: { type: 'content.part', stepUuid: 's1', part: { type: 'text', text: "Yes, I'm here." } } },
59
+ { type: 'turn.ended', time: 1004, turnId: 0, reason: 'completed' },
60
+ ]);
61
+
62
+ let scan = kimiCodeScan(loopEvents);
63
+ check('the reply is read off the loop events', scan.messages.some((m) => m.role === 'assistant' && m.text === "Yes, I'm here."));
64
+ check('the question is still there', scan.messages.some((m) => m.role === 'user' && m.text === 'are you there?'));
65
+ check('reasoning stays off the phone', !scan.messages.some((m) => m.text.includes('a simple check in')));
66
+ check('injected context is not a message', !scan.messages.some((m) => m.text.includes('system-reminder')));
67
+ check('turn.ended closes the turn, so not stuck busy', scan.busy === false);
68
+ check('every message carries a timestamp', scan.messages.every((m) => !!m.ts));
69
+
70
+ // A reply streamed as several text parts in one step is one message.
71
+ const split = write('split.jsonl', [
72
+ { type: 'turn.prompt', time: 2000, input: [{ type: 'text', text: 'hi' }] },
73
+ { type: 'context.append_loop_event', time: 2001, event: { type: 'content.part', stepUuid: 's2', part: { type: 'text', text: 'Connection ' } } },
74
+ { type: 'context.append_loop_event', time: 2002, event: { type: 'content.part', stepUuid: 's2', part: { type: 'text', text: 'looks good.' } } },
75
+ { type: 'turn.ended', time: 2003, reason: 'completed' },
76
+ ]);
77
+ scan = kimiCodeScan(split);
78
+ const replies = scan.messages.filter((m) => m.role === 'assistant');
79
+ check('split parts join into one reply', replies.length === 1 && replies[0].text === 'Connection looks good.',
80
+ JSON.stringify(replies.map((m) => m.text)));
81
+
82
+ // An older build that writes the assistant message too must not double up.
83
+ const both = write('both.jsonl', [
84
+ { type: 'turn.prompt', time: 3000, input: [{ type: 'text', text: 'hi' }] },
85
+ { type: 'context.append_loop_event', time: 3001, event: { type: 'content.part', stepUuid: 's3', part: { type: 'text', text: 'Hello there.' } } },
86
+ { type: 'context.append_message', time: 3002, message: { role: 'assistant', content: [{ type: 'text', text: 'Hello there.' }] } },
87
+ ]);
88
+ scan = kimiCodeScan(both);
89
+ check('a build writing both shapes does not double the reply',
90
+ scan.messages.filter((m) => m.role === 'assistant').length === 1);
91
+
92
+ // And the legacy shape on its own still works.
93
+ const legacyOnly = write('legacy.jsonl', [
94
+ { type: 'turn.prompt', time: 4000, input: [{ type: 'text', text: 'hi' }] },
95
+ { type: 'context.append_message', time: 4001, message: { role: 'assistant', content: [{ type: 'text', text: 'Legacy reply.' }] } },
96
+ ]);
97
+ scan = kimiCodeScan(legacyOnly);
98
+ check('the older assistant message shape still reads', scan.messages.some((m) => m.role === 'assistant' && m.text === 'Legacy reply.'));
99
+ check('the older shape still closes the turn', scan.busy === false);
100
+
101
+ // A turn that has been opened and not closed is genuinely busy.
102
+ const working = write('busy.jsonl', [
103
+ { type: 'turn.prompt', time: 5000, input: [{ type: 'text', text: 'do a long thing' }] },
104
+ ]);
105
+ check('an open turn still reads as busy', kimiCodeScan(working).busy === true);
106
+
107
+ fs.rmSync(tmp, { recursive: true, force: true });
108
+
109
+ // Against whatever this Mac actually has.
110
+ const root = path.join(os.homedir(), '.kimi-code', 'sessions');
111
+ const wires = [];
112
+ const walk = (dir, depth) => {
113
+ let entries = [];
114
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
115
+ for (const e of entries) {
116
+ const p = path.join(dir, e.name);
117
+ if (e.isDirectory() && depth < 4) walk(p, depth + 1);
118
+ else if (e.name === 'wire.jsonl') wires.push(p);
119
+ }
120
+ };
121
+ walk(root, 0);
122
+
123
+ if (!wires.length) {
124
+ console.log('\nskipped the live checks: no Kimi Code sessions on this Mac');
125
+ } else {
126
+ console.log(`\n${wires.length} live journal(s)`);
127
+ for (const w of wires) {
128
+ const live = kimiCodeScan(w);
129
+ const answered = live.messages.filter((m) => m.role === 'assistant').length;
130
+ const asked = live.messages.filter((m) => m.role === 'user').length;
131
+ console.log(` ${path.basename(path.dirname(path.dirname(path.dirname(w))))}: ${asked} asked, ${answered} answered, busy=${live.busy}`);
132
+ // The whole point: a journal with questions in it must have answers too.
133
+ if (asked > 0) check(`live journal has replies, not just prompts`, answered > 0);
134
+ }
135
+ }
136
+
137
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
138
+ process.exit(failures ? 1 : 0);
@@ -0,0 +1,67 @@
1
+ // Every pilot on the island wears a tag saying which agent it is, and the tag
2
+ // comes from the `provider` field on the session. A reader that builds a session
3
+ // without one puts an unlabelled pilot on the island, which is a silent failure:
4
+ // the invader looks fine and simply refuses to say what it is.
5
+ //
6
+ // This is a static check on the source rather than a runtime one, because
7
+ // building a real session means having that agent installed. Cheap, and it
8
+ // catches the actual mistake: adding a provider and forgetting the field.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
14
+ const src = fs.readFileSync(DAEMON, 'utf8');
15
+ const lines = src.split('\n');
16
+
17
+ let failures = 0;
18
+ const check = (label, cond, detail = '') => {
19
+ console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
20
+ if (!cond) failures++;
21
+ };
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.
25
+ const blocks = [];
26
+ lines.forEach((line, i) => {
27
+ if (!/sessions\.push\(\{\s*$/.test(line.trim())) return;
28
+ let depth = 1;
29
+ const body = [];
30
+ for (let j = i + 1; j < lines.length && depth > 0; j++) {
31
+ for (const ch of lines[j]) {
32
+ if (ch === '{') depth++;
33
+ else if (ch === '}') depth--;
34
+ }
35
+ if (depth > 0) body.push(lines[j]);
36
+ }
37
+ blocks.push({ line: i + 1, body: body.join('\n') });
38
+ });
39
+
40
+ check('found the session builders', blocks.length >= 4, `${blocks.length} found`);
41
+
42
+ for (const b of blocks) {
43
+ const match = b.body.match(/provider:\s*'([^']+)'/);
44
+ check(`daemon.mjs:${b.line} sets a provider`, !!match, match ? match[1] : b.body.split('\n')[0].trim());
45
+ }
46
+
47
+ // The Swift side switches on these exact strings. A provider the app has never
48
+ // heard of still renders, uppercased, but a rename would silently drop a brand
49
+ // name that used to be there, so the known set is pinned.
50
+ 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']) {
52
+ check(`${known} is still a provider the daemon emits`, providers.has(known));
53
+ }
54
+
55
+ const MODELS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'ios', 'GroundControl', 'Models.swift');
56
+ if (fs.existsSync(MODELS)) {
57
+ const swift = fs.readFileSync(MODELS, 'utf8');
58
+ check('the app decodes provider at all', /let provider: String\?/.test(swift));
59
+ for (const p of providers) {
60
+ check(`the app has a label for ${p}`, swift.includes(`"${p}"`));
61
+ }
62
+ } else {
63
+ console.log('skipped the app side: Models.swift not found');
64
+ }
65
+
66
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
67
+ 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);