@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 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.8';
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; a text-only assistant message
1236
- // (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.
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) messages.push({ role: 'assistant', text: text.slice(0, 4000), ts });
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
- const scan = memoScan(`kimicode:${wire}`, wire, () => kimiCodeScan(wire, 64 * 1024, 1));
1302
- const busy = (scan.busy || (!scan.sawMarkers && Date.now() - mtime < 60_000))
1303
- && Date.now() - mtime < KIMI_BUSY_STALE_MS;
1304
- // A pending approval outranks busy: the agent is stopped on a human.
1305
- const status = scan.waiting ? 'waiting' : (headlessActive.has(state.id) || busy ? 'busy' : 'idle');
1306
- transcriptCache.set(state.id, wire);
1307
- sessions.push({
1308
- id: state.id,
1309
- provider: 'kimicode',
1310
- name: state.title || (state.lastPrompt || '').slice(0, 40) || path.basename(state.cwd || '?'),
1311
- cwd: state.cwd || '',
1312
- project: path.basename(state.cwd || '?'),
1313
- status,
1314
- updatedAt: mtime,
1315
- startedAt: state.createdAt || 0,
1316
- lastMessage: scan.messages.length ? scan.messages[scan.messages.length - 1] : null,
1317
- tty: '',
1318
- live: false,
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.8",
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);