@clastres/groundcontrol 0.1.8 → 0.1.10

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.10';
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
 
@@ -1564,6 +1673,10 @@ function hostState() {
1564
1673
  settingsOk: settings !== null,
1565
1674
  socketOk: approvalServerUp,
1566
1675
  },
1676
+ // Which agents the phone may start here. An empty list is a real answer
1677
+ // (nothing launchable found); the whole field missing is an older daemon,
1678
+ // and the app hides the button rather than offering one that cannot work.
1679
+ launch: { providers: launchProviders() },
1567
1680
  updateAvailable: availableUpdate,
1568
1681
  };
1569
1682
  hostCache = { at: Date.now(), value };
@@ -1997,6 +2110,14 @@ async function handleCommand(cmd) {
1997
2110
  settleAsk(ask, { behavior: 'deny', message: reason || 'Denied from the Ground Control app.' },
1998
2111
  'denied', reason ? `from the phone: ${reason.slice(0, 60)}` : 'from the phone');
1999
2112
  }
2113
+ } else if (cmd.type === 'projects') {
2114
+ ws?.send(seal({
2115
+ type: 'projects',
2116
+ projects: knownProjects().map((p) => ({ path: p.path, name: p.name, at: p.at })),
2117
+ providers: launchProviders(),
2118
+ }));
2119
+ } else if (cmd.type === 'launch') {
2120
+ await launchSession(cmd);
2000
2121
  } else if (cmd.type === 'ping' && cmd.sessionId) {
2001
2122
  const s = readSessions().find((x) => x.id === cmd.sessionId);
2002
2123
  ws?.send(seal({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() }));
@@ -2149,6 +2270,166 @@ function promptHeadless(session, text) {
2149
2270
  watchTranscript(session.id);
2150
2271
  }
2151
2272
 
2273
+ // ---------- launching a new pilot ----------
2274
+ // Everything above answers a session that already exists. This starts one.
2275
+ //
2276
+ // The phone never sends a path. It picks from a list this daemon built out of
2277
+ // what is already on disk, and the launch validates the choice against that
2278
+ // same list before running anything, so the worst a stolen channel can do is
2279
+ // open a terminal in a folder you have already worked in today.
2280
+
2281
+ const PROJECT_LIMIT = 40;
2282
+ /// A folder nobody has touched in three months is not what you meant to open
2283
+ /// from your phone, and the list has to fit on a phone screen.
2284
+ const PROJECT_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
2285
+
2286
+ /// The real working directory behind one of Claude Code's project folders.
2287
+ ///
2288
+ /// The folder name is the path with every slash turned into a dash, which
2289
+ /// cannot be undone: a directory with a dash in its own name decodes to
2290
+ /// something that was never there. The transcripts inside record the actual
2291
+ /// cwd, so read it rather than guess it.
2292
+ function projectCwd(dir) {
2293
+ let files = [];
2294
+ try {
2295
+ files = fs.readdirSync(dir)
2296
+ .filter((f) => f.endsWith('.jsonl'))
2297
+ .map((f) => {
2298
+ const p = path.join(dir, f);
2299
+ try { return { p, at: fs.statSync(p).mtimeMs }; } catch { return null; }
2300
+ })
2301
+ .filter(Boolean)
2302
+ .sort((a, b) => b.at - a.at);
2303
+ } catch { return null; }
2304
+ if (!files.length) return null;
2305
+
2306
+ for (const { p, at } of files.slice(0, 3)) {
2307
+ let head = '';
2308
+ try {
2309
+ const fd = fs.openSync(p, 'r');
2310
+ const buf = Buffer.alloc(64 * 1024);
2311
+ const read = fs.readSync(fd, buf, 0, buf.length, 0);
2312
+ fs.closeSync(fd);
2313
+ head = buf.subarray(0, read).toString('utf8');
2314
+ } catch { continue; }
2315
+ for (const line of head.split('\n')) {
2316
+ if (!line.trim()) continue;
2317
+ let e; try { e = JSON.parse(line); } catch { continue; }
2318
+ if (typeof e.cwd === 'string' && e.cwd) return { cwd: e.cwd, at };
2319
+ }
2320
+ }
2321
+ return null;
2322
+ }
2323
+
2324
+ let projectsCache = { at: 0, value: [] };
2325
+ function knownProjects() {
2326
+ if (Date.now() - projectsCache.at < 60_000) return projectsCache.value;
2327
+ const byPath = new Map();
2328
+ const note = (cwd, at) => {
2329
+ if (!cwd || !fs.existsSync(cwd)) return;
2330
+ const prev = byPath.get(cwd);
2331
+ if (!prev || prev.at < at) byPath.set(cwd, { path: cwd, name: path.basename(cwd) || cwd, at });
2332
+ };
2333
+
2334
+ let dirs = [];
2335
+ try { dirs = fs.readdirSync(PROJECTS_DIR); } catch {}
2336
+ for (const d of dirs) {
2337
+ const found = projectCwd(path.join(PROJECTS_DIR, d));
2338
+ if (found) note(found.cwd, found.at);
2339
+ }
2340
+ // Whatever is flying right now, even if its transcript has not landed yet.
2341
+ for (const s of readSessions()) note(s.cwd, Date.now());
2342
+
2343
+ const cutoff = Date.now() - PROJECT_MAX_AGE_MS;
2344
+ const value = [...byPath.values()]
2345
+ .filter((p) => p.at >= cutoff)
2346
+ .sort((a, b) => b.at - a.at)
2347
+ .slice(0, PROJECT_LIMIT);
2348
+ projectsCache = { at: Date.now(), value };
2349
+ return value;
2350
+ }
2351
+
2352
+ /// Which agents this Mac can start from the phone.
2353
+ ///
2354
+ /// Claude Code only, on purpose. Every provider here has to be one whose bare
2355
+ /// command opens an interactive session that then shows up on the island, and
2356
+ /// claude is the one that has been checked. Codex and the kimi CLIs are read
2357
+ /// and written by the daemon already, but nobody has watched either of them
2358
+ /// come up this way, so offering them would be a button that might do nothing.
2359
+ function launchProviders() {
2360
+ return claudeBin() ? ['claude'] : [];
2361
+ }
2362
+
2363
+ /// Single quotes for the shell, then AppleScript quoting on top. Both layers
2364
+ /// matter: the directory comes from disk and the first order comes from your
2365
+ /// thumbs.
2366
+ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
2367
+
2368
+ let lastLaunchAt = 0;
2369
+
2370
+ async function launchSession(cmd) {
2371
+ const fail = (error) => ws?.send(seal({ type: 'launchResult', ok: false, error }));
2372
+
2373
+ const providers = launchProviders();
2374
+ const provider = cmd.provider || 'claude';
2375
+ if (!providers.includes(provider)) {
2376
+ return fail(`This Mac cannot start a ${provider} session from the phone. Ground Control could not find the ${provider} command on your PATH.`);
2377
+ }
2378
+ const target = knownProjects().find((p) => p.path === cmd.cwd);
2379
+ if (!target) {
2380
+ // The list is a minute old at most, so this is almost always a folder that
2381
+ // was renamed or deleted since it was drawn.
2382
+ return fail('That folder is not on this Mac any more. Pull the list again.');
2383
+ }
2384
+ if (Date.now() - lastLaunchAt < 5000) {
2385
+ return fail('One at a time. Give the last launch a few seconds.');
2386
+ }
2387
+
2388
+ const apps = terminalApps();
2389
+ const first = (cmd.text || '').trim();
2390
+ // Claude Code takes an opening prompt as a positional argument and starts the
2391
+ // normal interactive session with it already submitted, which beats waiting a
2392
+ // guessed number of seconds for a REPL to boot and then typing at it.
2393
+ const line = `cd ${shq(target.path)} && ${provider}${first ? ` ${shq(first)}` : ''}`;
2394
+
2395
+ lastLaunchAt = Date.now();
2396
+ let opened = null;
2397
+ if (apps.iterm) {
2398
+ const r = await osascript(`tell application "iTerm2"
2399
+ activate
2400
+ set w to (create window with default profile)
2401
+ tell current session of w to write text "${escapeAS(line)}"
2402
+ return "ok"
2403
+ end tell`);
2404
+ if (r === 'ok') opened = 'iTerm2';
2405
+ }
2406
+ if (!opened) {
2407
+ // Terminal.app is on every Mac, and telling it to do a script launches it
2408
+ // if it was closed, so this is the path that always exists.
2409
+ const r = await osascript(`tell application "Terminal"
2410
+ activate
2411
+ do script "${escapeAS(line)}"
2412
+ return "ok"
2413
+ end tell`);
2414
+ if (r === 'ok') opened = 'Terminal';
2415
+ }
2416
+
2417
+ if (!opened) {
2418
+ lastLaunchAt = 0;
2419
+ if (automationDenied) {
2420
+ return fail('Your Mac is blocking terminal control. Open System Settings, Privacy and Security, Automation, and allow node to control your terminal app. Then try again.');
2421
+ }
2422
+ return fail('Could not open a terminal window on your Mac.');
2423
+ }
2424
+
2425
+ console.log(`[daemon] launched ${provider} in ${target.path} via ${opened}`);
2426
+ // The new session reaches the island through the same discovery every other
2427
+ // one does, which takes a few seconds. Say so rather than implying the pilot
2428
+ // is already there.
2429
+ ws?.send(seal({ type: 'launchResult', ok: true, cwd: target.path, note: `${provider} is starting in ${target.name}` }));
2430
+ sendSnapshot(true);
2431
+ }
2432
+
2152
2433
  let lastPongAt = Date.now();
2153
2434
 
2154
2435
  function connect() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clastres/groundcontrol",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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,73 @@
1
+ // Starting a session from the phone crosses the two languages twice: the app
2
+ // sends a command the daemon has to know, and the daemon answers with a reply
3
+ // type the app has to decode. Either half can be renamed on its own and
4
+ // nothing complains until a button on a phone quietly does nothing.
5
+ //
6
+ // Static checks on both sources, in the same spirit as providers.mjs. They do
7
+ // not prove a terminal opens; they prove the two ends are still talking about
8
+ // the same thing.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const here = path.dirname(fileURLToPath(import.meta.url));
14
+ const daemon = fs.readFileSync(path.join(here, '..', 'daemon.mjs'), 'utf8');
15
+ const app = path.join(here, '..', '..', 'ios', 'GroundControl');
16
+ const client = fs.readFileSync(path.join(app, 'RelayClient.swift'), 'utf8');
17
+ const models = fs.readFileSync(path.join(app, 'Models.swift'), 'utf8');
18
+
19
+ let failures = 0;
20
+ const check = (label, cond, detail = '') => {
21
+ console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
22
+ if (!cond) failures++;
23
+ };
24
+
25
+ // ---------- every command the app sends, the daemon handles ----------
26
+
27
+ const sent = [...client.matchAll(/Command\(type:\s*"([^"]+)"/g)].map((m) => m[1]);
28
+ check('the app sends commands at all', sent.length >= 8, `${sent.length} found`);
29
+ for (const type of [...new Set(sent)].sort()) {
30
+ check(`the daemon handles "${type}"`, daemon.includes(`cmd.type === '${type}'`));
31
+ }
32
+
33
+ // ---------- every reply the daemon sends, the app decodes ----------
34
+
35
+ // hb is the daemon's heartbeat. It carries nothing, and the app wants it only
36
+ // for the fact that something arrived, which every message does: receiving it
37
+ // stamps lastActivity and it falls through the switch on purpose.
38
+ const replies = [...daemon.matchAll(/seal\(\{\s*\n?\s*type:\s*'([^']+)'/g)].map((m) => m[1]);
39
+ for (const type of [...new Set(replies)].sort().filter((t) => t !== 'hb')) {
40
+ // The app switches on envelope type. "snapshot" and the rest each get a case.
41
+ check(`the app decodes "${type}"`, client.includes(`case "${type}"`));
42
+ }
43
+
44
+ // ---------- the launch payload ----------
45
+
46
+ check('the launch command carries a folder', /var cwd: String\?/.test(models));
47
+ check('the launch command carries a provider', /var provider: String\?/.test(models));
48
+ check('the app models the folders the Mac offers', /struct ProjectDir/.test(models));
49
+ check('the app models what the Mac will launch', /struct LaunchInfo/.test(models));
50
+ check('the host block reports launchable providers', /launch:\s*\{\s*providers:\s*launchProviders\(\)\s*\}/.test(daemon));
51
+
52
+ // ---------- the folder has to come from the Mac's own list ----------
53
+ // The phone must never be able to name a path. If this stops being true, a
54
+ // stolen channel turns into "run an agent anywhere on this Mac".
55
+
56
+ const body = daemon.slice(daemon.indexOf('async function launchSession'));
57
+ check('a launch validates the folder against the offered list',
58
+ /knownProjects\(\)\.find\(\(p\) => p\.path === cmd\.cwd\)/.test(body));
59
+ check('a launch refuses a folder that is not on the list',
60
+ body.indexOf('return fail(') < body.indexOf('osascript'));
61
+
62
+ // ---------- only what has actually been checked is offered ----------
63
+ // Codex and the kimi CLIs are read and written elsewhere in this daemon, but
64
+ // nobody has watched either of them come up from a cold terminal launch, so
65
+ // they are deliberately not on this list. Adding one here without checking it
66
+ // ships a button that may do nothing.
67
+
68
+ const providers = daemon.slice(daemon.indexOf('function launchProviders'));
69
+ const listed = providers.slice(0, providers.indexOf('}'));
70
+ check('only claude is offered for launch', /claudeBin\(\) \? \['claude'\] : \[\]/.test(listed));
71
+
72
+ console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
73
+ process.exit(failures ? 1 : 0);
@@ -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);