@clastres/groundcontrol 0.1.8 → 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 +134 -25
- package/package.json +1 -1
- package/test/kimi-liveness.mjs +78 -0
- package/test/kimi.mjs +138 -0
- package/test/providers.mjs +67 -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 ----------
|
|
@@ -1232,14 +1232,38 @@ function readKimiLegacySessions() {
|
|
|
1232
1232
|
// snapshot interval, where an exception would crash-loop the whole daemon.
|
|
1233
1233
|
const isoOf = (t) => { try { return t ? new Date(t).toISOString() : null; } catch { return null; } };
|
|
1234
1234
|
|
|
1235
|
-
// Kimi Code journal: turn.prompt opens a turn;
|
|
1236
|
-
//
|
|
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.
|
|
1237
1244
|
function kimiCodeScan(wireFile, maxBytes = 512 * 1024, limit = 80) {
|
|
1238
1245
|
let lines;
|
|
1239
1246
|
try { lines = tailLines(wireFile, maxBytes); } catch { return { messages: [], busy: false, sawMarkers: true, waiting: false }; }
|
|
1240
1247
|
const messages = [];
|
|
1241
1248
|
let lastPrompt = 0, lastEnd = 0;
|
|
1242
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
|
+
|
|
1243
1267
|
for (const line of lines) {
|
|
1244
1268
|
if (!line.trim()) continue;
|
|
1245
1269
|
let e; try { e = JSON.parse(line); } catch { continue; }
|
|
@@ -1250,12 +1274,23 @@ function kimiCodeScan(wireFile, maxBytes = 512 * 1024, limit = 80) {
|
|
|
1250
1274
|
if (text) messages.push({ role: 'user', text: text.slice(0, 4000), ts });
|
|
1251
1275
|
} else if (e.type === 'turn.cancel') {
|
|
1252
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
|
+
}
|
|
1253
1288
|
} else if (e.type === 'context.append_message') {
|
|
1254
1289
|
const m = e.message || {};
|
|
1255
1290
|
const text = kosongText(m.content).trim();
|
|
1256
1291
|
const toolCalls = m.toolCalls || m.tool_calls;
|
|
1257
1292
|
if (m.role === 'assistant' && !(toolCalls && toolCalls.length)) {
|
|
1258
|
-
if (text)
|
|
1293
|
+
if (text) pushAssistant(text, ts, null);
|
|
1259
1294
|
lastEnd = Math.max(lastEnd, e.time || 0);
|
|
1260
1295
|
}
|
|
1261
1296
|
} else if (e.type === 'interaction.request') {
|
|
@@ -1267,7 +1302,7 @@ function kimiCodeScan(wireFile, maxBytes = 512 * 1024, limit = 80) {
|
|
|
1267
1302
|
}
|
|
1268
1303
|
}
|
|
1269
1304
|
return {
|
|
1270
|
-
messages: messages.slice(-limit),
|
|
1305
|
+
messages: messages.slice(-limit).map((m) => ({ ...m, text: m.text.trim() })),
|
|
1271
1306
|
busy: lastPrompt > lastEnd,
|
|
1272
1307
|
// Same tail-window caveat as Codex: no markers seen means a long turn
|
|
1273
1308
|
// pushed them out, so freshness decides.
|
|
@@ -1276,6 +1311,49 @@ function kimiCodeScan(wireFile, maxBytes = 512 * 1024, limit = 80) {
|
|
|
1276
1311
|
};
|
|
1277
1312
|
}
|
|
1278
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
|
+
|
|
1279
1357
|
function readKimiCodeMessages(file, maxBytes = 512 * 1024, limit = 80) {
|
|
1280
1358
|
return kimiCodeScan(file, maxBytes, limit).messages;
|
|
1281
1359
|
}
|
|
@@ -1285,6 +1363,11 @@ function readKimiCodeSessions() {
|
|
|
1285
1363
|
const root = path.join(KIMICODE_DIR, 'sessions');
|
|
1286
1364
|
let wdKeys = [];
|
|
1287
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 = [];
|
|
1288
1371
|
for (const wdKey of wdKeys) {
|
|
1289
1372
|
let ids = [];
|
|
1290
1373
|
try { ids = fs.readdirSync(path.join(root, wdKey)); } catch { continue; }
|
|
@@ -1298,27 +1381,53 @@ function readKimiCodeSessions() {
|
|
|
1298
1381
|
let mtime = state.updatedAt || 0;
|
|
1299
1382
|
try { mtime = Math.max(mtime, fs.statSync(wire).mtimeMs); } catch {}
|
|
1300
1383
|
if (Date.now() - mtime > KIMI_ACTIVE_WINDOW_MS) continue;
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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));
|
|
1320
1407
|
}
|
|
1321
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
|
+
}
|
|
1322
1431
|
return sessions;
|
|
1323
1432
|
}
|
|
1324
1433
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
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"
|
|
@@ -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);
|