@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 +338 -41
- package/package.json +8 -2
- package/test/binaries.mjs +68 -0
- package/test/grokbot.mjs +86 -0
- package/test/kimi-liveness.mjs +78 -0
- package/test/kimi.mjs +138 -0
- package/test/providers.mjs +67 -0
- package/test/run.mjs +23 -0
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.
|
|
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
|
-
|
|
642
|
-
|
|
641
|
+
// Every agent binary is resolved on use rather than at startup. launchd starts
|
|
642
|
+
// this at login and it then runs for weeks, while CLIs get installed
|
|
643
|
+
// underneath it. Session discovery already rescans the disk every few seconds,
|
|
644
|
+
// so a `const` resolved at boot produces the worst possible split: the new
|
|
645
|
+
// session appears on the phone, its transcript streams, and sending fails with
|
|
646
|
+
// "binary not found" until someone restarts the daemon. Cached for a minute so
|
|
647
|
+
// a prompt does not pay for a PATH scan.
|
|
648
|
+
const binMemo = new Map(); // label -> { at, path }
|
|
649
|
+
function resolveBin(label, find) {
|
|
650
|
+
const c = binMemo.get(label);
|
|
651
|
+
if (c && Date.now() - c.at < 60_000) return c.path;
|
|
652
|
+
let p = null;
|
|
653
|
+
try { p = find() || null; } catch {}
|
|
654
|
+
binMemo.set(label, { at: Date.now(), path: p });
|
|
655
|
+
return p;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const claudeBin = () => resolveBin('claude', () => which('claude'));
|
|
659
|
+
console.log(`[daemon] claude binary: ${claudeBin() || 'NOT FOUND (phone prompts to idle sessions without a visible terminal will fail)'}`);
|
|
643
660
|
|
|
644
661
|
// Codex ships a full CLI inside the ChatGPT desktop app, so Codex Desktop
|
|
645
662
|
// users can receive phone prompts even without a standalone install.
|
|
646
|
-
const codexBin = which('codex') || executable('/Applications/ChatGPT.app/Contents/Resources/codex');
|
|
663
|
+
const codexBin = () => resolveBin('codex', () => which('codex') || executable('/Applications/ChatGPT.app/Contents/Resources/codex'));
|
|
647
664
|
|
|
648
665
|
// Kimi Code renames a legacy Python `kimi` shim to `kimi-legacy` on install,
|
|
649
666
|
// so when the Kimi Code store (~/.kimi-code) exists, `kimi` on PATH belongs
|
|
650
667
|
// to Kimi Code and `kimi-legacy` is the old CLI. Without the store, `kimi` is
|
|
651
668
|
// the legacy one. The two binaries cannot resume each other's sessions, so
|
|
652
669
|
// neither ever doubles for the other.
|
|
653
|
-
const kimiCodeInstalled = fs.existsSync(path.join(os.homedir(), '.kimi-code'));
|
|
654
|
-
const kimiCodeBin = kimiCodeInstalled
|
|
670
|
+
const kimiCodeInstalled = () => fs.existsSync(path.join(os.homedir(), '.kimi-code'));
|
|
671
|
+
const kimiCodeBin = () => resolveBin('kimicode', () => (kimiCodeInstalled()
|
|
655
672
|
? which('kimi') || executable(path.join(os.homedir(), '.kimi-code', 'bin', 'kimi'))
|
|
656
|
-
: null;
|
|
657
|
-
const kimiLegacyBin = which('kimi-legacy') || (kimiCodeInstalled ? null : which('kimi'));
|
|
673
|
+
: null));
|
|
674
|
+
const kimiLegacyBin = () => resolveBin('kimi', () => which('kimi-legacy') || (kimiCodeInstalled() ? null : which('kimi')));
|
|
658
675
|
|
|
659
676
|
// ---------- Claude Code session discovery ----------
|
|
660
677
|
|
|
@@ -850,6 +867,7 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
|
|
|
850
867
|
// path.sep keeps the prefixes unambiguous: without it ~/.kimi would also
|
|
851
868
|
// match every ~/.kimi-code path and routing would depend on check order.
|
|
852
869
|
if (file.startsWith(CODEX_SESS_DIR + path.sep)) return readCodexMessages(file, maxBytes, limit);
|
|
870
|
+
if (file.startsWith(GROKBOT_DIR + path.sep)) return readGrokBotMessages(file, maxBytes, limit);
|
|
853
871
|
if (file.startsWith(KIMICODE_DIR + path.sep)) return readKimiCodeMessages(file, maxBytes, limit);
|
|
854
872
|
if (file.startsWith(KIMI_DIR + path.sep)) return readKimiLegacyMessages(file, maxBytes, limit);
|
|
855
873
|
let raw;
|
|
@@ -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;
|
|
1218
|
-
//
|
|
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)
|
|
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
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1902
|
-
kimicode: () => kimiCodeBin && { bin
|
|
1903
|
-
kimi: () => kimiLegacyBin && { bin
|
|
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,
|
|
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.
|
|
4
|
-
"description": "See and command your
|
|
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);
|
package/test/grokbot.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Runs the real Grok Bot reader out of daemon.mjs against whatever replica this
|
|
2
|
+
// Mac has. The block is extracted from source rather than copied, so the test
|
|
3
|
+
// cannot drift from what the daemon ships.
|
|
4
|
+
//
|
|
5
|
+
// The daemon is one long script that connects to the relay on import, so there
|
|
6
|
+
// is nothing to import: pulling the section out by its comment markers and
|
|
7
|
+
// evaluating it with its handful of helpers injected is what keeps this honest.
|
|
8
|
+
//
|
|
9
|
+
// On a Mac without Grok Bot installed the roster assertions are skipped and the
|
|
10
|
+
// machine independent ones still run.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
|
|
17
|
+
const src = fs.readFileSync(DAEMON, 'utf8');
|
|
18
|
+
const start = src.indexOf('// ---------- Grok Bot session discovery ----------');
|
|
19
|
+
const end = src.indexOf('/// The phone-facing shape of a held request.');
|
|
20
|
+
if (start < 0 || end < 0 || end < start) throw new Error('could not locate the Grok Bot block in daemon.mjs');
|
|
21
|
+
const block = src.slice(start, end);
|
|
22
|
+
|
|
23
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
24
|
+
const isoOf = (t) => { try { return t ? new Date(t).toISOString() : null; } catch { return null; } };
|
|
25
|
+
const memoScan = (_key, _file, fn) => fn();
|
|
26
|
+
const transcriptCache = new Map();
|
|
27
|
+
|
|
28
|
+
const load = (source, cache) => new Function('fs', 'os', 'path', 'readJson', 'isoOf', 'memoScan', 'transcriptCache',
|
|
29
|
+
`${source}\n return { readGrokBotSessions, readGrokBotMessages, b32decode, GROKBOT_DIR };`)(
|
|
30
|
+
fs, os, path, readJson, isoOf, memoScan, cache);
|
|
31
|
+
|
|
32
|
+
const { readGrokBotSessions, readGrokBotMessages, b32decode, GROKBOT_DIR } = load(block, transcriptCache);
|
|
33
|
+
|
|
34
|
+
let failures = 0;
|
|
35
|
+
const check = (label, cond, detail = '') => {
|
|
36
|
+
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
37
|
+
if (!cond) failures++;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// A store key the app really writes, so a change to the encoding is caught here
|
|
41
|
+
// rather than by an empty island.
|
|
42
|
+
const known = 'onqw4zbomnwgszlooqxhg3djmnss4y3mnfsw45bnnvsxiyjomfrwg33vnz2c243mn52a';
|
|
43
|
+
check('b32decode reads a real store key', b32decode(known) === 'sand.client.slice.client-meta.account-slot', b32decode(known));
|
|
44
|
+
check('b32decode rejects junk', b32decode('!!!!') === '');
|
|
45
|
+
|
|
46
|
+
// A Mac without Grok Bot must return an empty list rather than throw.
|
|
47
|
+
check('missing install returns empty, does not throw',
|
|
48
|
+
load(block.replace(/const GROKBOT_DIR = .*/, "const GROKBOT_DIR = '/nope/not/here';"), new Map())
|
|
49
|
+
.readGrokBotSessions().length === 0);
|
|
50
|
+
|
|
51
|
+
const sessions = fs.existsSync(GROKBOT_DIR) ? readGrokBotSessions() : [];
|
|
52
|
+
if (!sessions.length) {
|
|
53
|
+
console.log(`\nskipped the roster checks: no Bots found in ${GROKBOT_DIR}`);
|
|
54
|
+
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
|
55
|
+
process.exit(failures ? 1 : 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(`\n${sessions.length} bot(s) found\n`);
|
|
59
|
+
for (const s of sessions) {
|
|
60
|
+
check(`${s.name}: id is a uuid`, /^[0-9a-f-]{36}$/.test(s.id));
|
|
61
|
+
check(`${s.name}: status is one the app understands`, ['idle', 'busy', 'waiting'].includes(s.status));
|
|
62
|
+
check(`${s.name}: has a name`, !!s.name);
|
|
63
|
+
check(`${s.name}: startedAt set`, s.startedAt > 0);
|
|
64
|
+
check(`${s.name}: updatedAt set`, s.updatedAt > 0);
|
|
65
|
+
check(`${s.name}: read only flagged`, s.readOnly === true);
|
|
66
|
+
// Every field the Swift Session struct decodes as non-optional must be here,
|
|
67
|
+
// or the phone drops the whole snapshot, not just this row.
|
|
68
|
+
for (const k of ['id', 'name', 'cwd', 'project', 'status', 'updatedAt', 'startedAt']) {
|
|
69
|
+
check(`${s.name}: ${k} present for Swift decode`, s[k] !== undefined && s[k] !== null);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const file = transcriptCache.get(s.id);
|
|
73
|
+
check(`${s.name}: transcript located`, !!file);
|
|
74
|
+
if (!file) continue;
|
|
75
|
+
const msgs = readGrokBotMessages(file);
|
|
76
|
+
check(`${s.name}: transcript has messages`, msgs.length > 0);
|
|
77
|
+
check(`${s.name}: roles are user/assistant only`, msgs.every((m) => m.role === 'user' || m.role === 'assistant'));
|
|
78
|
+
check(`${s.name}: every message has a timestamp`, msgs.every((m) => !!m.ts));
|
|
79
|
+
check(`${s.name}: lastMessage matches transcript tail`, s.lastMessage?.text === msgs[msgs.length - 1]?.text);
|
|
80
|
+
// The app stores a ProseMirror doc beside every typed turn. Leaking it into
|
|
81
|
+
// the transcript would put raw JSON on the phone.
|
|
82
|
+
check(`${s.name}: no raw richText leaked into text`, msgs.every((m) => !m.text.includes('"type":"doc"')));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
|
86
|
+
process.exit(failures ? 1 : 0);
|
|
@@ -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);
|