@clastres/groundcontrol 0.1.9 → 0.1.11
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 +241 -179
- package/package.json +2 -3
- package/test/approvals.mjs +121 -0
- package/test/codex.mjs +94 -0
- package/test/launch.mjs +73 -0
- package/test/providers.mjs +7 -4
- package/test/grokbot.mjs +0 -86
package/daemon.mjs
CHANGED
|
@@ -22,7 +22,7 @@ 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.11';
|
|
26
26
|
const PACKAGE = '@clastres/groundcontrol';
|
|
27
27
|
|
|
28
28
|
// ---------- config / pairing ----------
|
|
@@ -867,7 +867,6 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
|
|
|
867
867
|
// path.sep keeps the prefixes unambiguous: without it ~/.kimi would also
|
|
868
868
|
// match every ~/.kimi-code path and routing would depend on check order.
|
|
869
869
|
if (file.startsWith(CODEX_SESS_DIR + path.sep)) return readCodexMessages(file, maxBytes, limit);
|
|
870
|
-
if (file.startsWith(GROKBOT_DIR + path.sep)) return readGrokBotMessages(file, maxBytes, limit);
|
|
871
870
|
if (file.startsWith(KIMICODE_DIR + path.sep)) return readKimiCodeMessages(file, maxBytes, limit);
|
|
872
871
|
if (file.startsWith(KIMI_DIR + path.sep)) return readKimiLegacyMessages(file, maxBytes, limit);
|
|
873
872
|
let raw;
|
|
@@ -996,6 +995,15 @@ function codexMetaOf(file) {
|
|
|
996
995
|
id: j.payload.session_id,
|
|
997
996
|
cwd: j.payload.cwd || '',
|
|
998
997
|
startedAt: Date.parse(j.payload.timestamp || j.timestamp) || 0,
|
|
998
|
+
// Codex spawns its own helpers (the review subagent, for one) as
|
|
999
|
+
// separate rollout files that carry the *parent's* session_id. Two
|
|
1000
|
+
// files, one id: the phone would draw one pilot and leave a hole
|
|
1001
|
+
// where the other should be, because both the crew list and the
|
|
1002
|
+
// island key on the session id. A helper is not a pilot anyway, so
|
|
1003
|
+
// it is marked here and dropped by the scan, which keeps the answer
|
|
1004
|
+
// in the cache rather than re-reading a growing file every sweep.
|
|
1005
|
+
helper: j.payload.thread_source === 'subagent'
|
|
1006
|
+
|| (!!j.payload.parent_thread_id && j.payload.parent_thread_id !== j.payload.id),
|
|
999
1007
|
};
|
|
1000
1008
|
}
|
|
1001
1009
|
} catch {}
|
|
@@ -1090,17 +1098,29 @@ function readCodexSessions() {
|
|
|
1090
1098
|
codexCache.at = Date.now();
|
|
1091
1099
|
const names = codexThreadNames();
|
|
1092
1100
|
const sessions = [];
|
|
1101
|
+
// One pilot per folder, not one per run.
|
|
1102
|
+
//
|
|
1103
|
+
// Codex has no long lived session the way Claude Code does: every `codex
|
|
1104
|
+
// exec` writes its own rollout, so asking it to review a change three times
|
|
1105
|
+
// in an afternoon used to put three identical pilots on the island, all in
|
|
1106
|
+
// the same directory, none of them the one you meant. The freshest rollout
|
|
1107
|
+
// in a folder is the one worth talking to, and prompting resumes it by id,
|
|
1108
|
+
// so the older ones have nothing left to offer.
|
|
1109
|
+
const newestPerCwd = new Map(); // cwd -> index into sessions
|
|
1093
1110
|
for (const file of codexRolloutFiles()) {
|
|
1094
1111
|
let mtime;
|
|
1095
1112
|
try { mtime = fs.statSync(file).mtimeMs; } catch { continue; }
|
|
1096
1113
|
if (Date.now() - mtime > CODEX_ACTIVE_WINDOW_MS) continue;
|
|
1097
1114
|
const meta = codexMetaOf(file);
|
|
1098
|
-
if (!meta) continue;
|
|
1115
|
+
if (!meta || meta.helper) continue;
|
|
1099
1116
|
const act = memoScan(`codex:${file}`, file, () => codexActivityOf(file));
|
|
1100
1117
|
const busy = (act.busy || (!act.sawMarkers && Date.now() - mtime < 60_000))
|
|
1101
1118
|
&& Date.now() - mtime < CODEX_BUSY_STALE_MS;
|
|
1119
|
+
const key = meta.cwd || `id:${meta.id}`;
|
|
1120
|
+
const seen = newestPerCwd.get(key);
|
|
1121
|
+
if (seen !== undefined && sessions[seen].updatedAt >= mtime) continue;
|
|
1102
1122
|
transcriptCache.set(meta.id, file);
|
|
1103
|
-
|
|
1123
|
+
const session = {
|
|
1104
1124
|
id: meta.id,
|
|
1105
1125
|
provider: 'codex',
|
|
1106
1126
|
name: names.get(meta.id) || path.basename(meta.cwd || '?'),
|
|
@@ -1112,7 +1132,9 @@ function readCodexSessions() {
|
|
|
1112
1132
|
lastMessage: act.lastMessage,
|
|
1113
1133
|
tty: '',
|
|
1114
1134
|
live: false,
|
|
1115
|
-
}
|
|
1135
|
+
};
|
|
1136
|
+
if (seen !== undefined) sessions[seen] = session;
|
|
1137
|
+
else { newestPerCwd.set(key, sessions.length); sessions.push(session); }
|
|
1116
1138
|
}
|
|
1117
1139
|
codexCache.sessions = sessions;
|
|
1118
1140
|
return sessions;
|
|
@@ -1439,157 +1461,6 @@ function readKimiSessions() {
|
|
|
1439
1461
|
return kimiCache.sessions;
|
|
1440
1462
|
}
|
|
1441
1463
|
|
|
1442
|
-
// ---------- Grok Bot session discovery ----------
|
|
1443
|
-
// Grok Bot's agents run on a cloud VM, so unlike every other provider there is
|
|
1444
|
-
// nothing local to attach to: no process, no tty, no working directory, and no
|
|
1445
|
-
// way to send. What the desktop app does keep is a replica of what it has
|
|
1446
|
-
// synced, under ~/Library/Application Support/Grok Bot/sand-client-persistence,
|
|
1447
|
-
// one JSON blob per store key with the key base32 encoded into the filename.
|
|
1448
|
-
// Two keys matter:
|
|
1449
|
-
// <account>.roster.last-roster one row per Bot
|
|
1450
|
-
// <account>.transcript.replicas.<id> that Bot's conversation
|
|
1451
|
-
//
|
|
1452
|
-
// Read only, deliberately. The replica is the app's cache and not the source of
|
|
1453
|
-
// truth (that lives on the VM at /home/box/sand-data/agents/<id>/store.db), so
|
|
1454
|
-
// a message written in here is a message the app overwrites rather than sends.
|
|
1455
|
-
//
|
|
1456
|
-
// It also only advances while the desktop app is running. Bots keep working
|
|
1457
|
-
// with the app closed and the island cannot see it, so a Bot left mid turn is
|
|
1458
|
-
// the app having quit, not the Bot being stuck: busy expires on staleness the
|
|
1459
|
-
// same way the other journal-freshness providers do.
|
|
1460
|
-
|
|
1461
|
-
const GROKBOT_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'Grok Bot', 'sand-client-persistence');
|
|
1462
|
-
const GROKBOT_BUSY_STALE_MS = 5 * 60 * 1000;
|
|
1463
|
-
|
|
1464
|
-
// RFC 4648 base32, lowercase and unpadded, which is how the app names blobs.
|
|
1465
|
-
// A name that is not one of ours decodes to junk and simply matches nothing,
|
|
1466
|
-
// so guessing wrong costs a string compare.
|
|
1467
|
-
const B32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
1468
|
-
function b32decode(name) {
|
|
1469
|
-
let bits = 0;
|
|
1470
|
-
let value = 0;
|
|
1471
|
-
const out = [];
|
|
1472
|
-
for (const ch of name.toLowerCase()) {
|
|
1473
|
-
if (ch === '=') break;
|
|
1474
|
-
const i = B32_ALPHABET.indexOf(ch);
|
|
1475
|
-
if (i < 0) return '';
|
|
1476
|
-
value = (value << 5) | i;
|
|
1477
|
-
bits += 5;
|
|
1478
|
-
if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
|
|
1479
|
-
}
|
|
1480
|
-
return Buffer.from(out).toString('utf8');
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
// The names are long and never change, so decode each one once.
|
|
1484
|
-
const grokbotKeys = new Map(); // filename -> decoded store key
|
|
1485
|
-
const grokbotKeyOf = (name) => {
|
|
1486
|
-
if (!grokbotKeys.has(name)) grokbotKeys.set(name, b32decode(name.replace(/\.blob$/, '')));
|
|
1487
|
-
return grokbotKeys.get(name);
|
|
1488
|
-
};
|
|
1489
|
-
|
|
1490
|
-
const grokbotEntriesOf = (file) => {
|
|
1491
|
-
const entries = readJson(file)?.value?.entries;
|
|
1492
|
-
return Array.isArray(entries) ? entries : [];
|
|
1493
|
-
};
|
|
1494
|
-
|
|
1495
|
-
// Two entry kinds carry a turn: a typed one is {kind:'message', role, content}
|
|
1496
|
-
// and a Bot one is {kind:'send-message', message:{type,content}}. Everything
|
|
1497
|
-
// else in the stream (tool calls, approvals, status) has no place in a phone
|
|
1498
|
-
// transcript.
|
|
1499
|
-
function grokbotMessages(entries, limit = 80) {
|
|
1500
|
-
const out = [];
|
|
1501
|
-
for (const e of entries) {
|
|
1502
|
-
let role = null;
|
|
1503
|
-
let text = '';
|
|
1504
|
-
if (e?.kind === 'message' && (e.role === 'user' || e.role === 'assistant')) {
|
|
1505
|
-
role = e.role;
|
|
1506
|
-
text = typeof e.content === 'string' ? e.content : '';
|
|
1507
|
-
} else if (e?.kind === 'send-message' && e.message?.type === 'text') {
|
|
1508
|
-
role = 'assistant';
|
|
1509
|
-
text = typeof e.message.content === 'string' ? e.message.content : '';
|
|
1510
|
-
}
|
|
1511
|
-
text = text.trim();
|
|
1512
|
-
if (!role || !text) continue;
|
|
1513
|
-
out.push({ role, text: text.slice(0, 4000), ts: isoOf(e.timestampMs) });
|
|
1514
|
-
}
|
|
1515
|
-
return out.slice(-limit);
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
// One JSON object rather than a journal, so the tail slice every other provider
|
|
1519
|
-
// reads with would just be a syntax error. Whole file, with a ceiling that only
|
|
1520
|
-
// exists so a pathological blob cannot stall the daemon.
|
|
1521
|
-
function readGrokBotMessages(file, maxBytes = 512 * 1024, limit = 80) {
|
|
1522
|
-
try { if (fs.statSync(file).size > 8 * 1024 * 1024) return []; } catch { return []; }
|
|
1523
|
-
return grokbotMessages(grokbotEntriesOf(file), limit);
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
function scanGrokBotSessions() {
|
|
1527
|
-
const sessions = [];
|
|
1528
|
-
let files = [];
|
|
1529
|
-
try { files = fs.readdirSync(GROKBOT_DIR); } catch { return sessions; }
|
|
1530
|
-
|
|
1531
|
-
let rosterFile = null;
|
|
1532
|
-
const transcripts = new Map(); // botId -> blob path
|
|
1533
|
-
for (const name of files) {
|
|
1534
|
-
if (!name.endsWith('.blob')) continue;
|
|
1535
|
-
const key = grokbotKeyOf(name);
|
|
1536
|
-
if (key.endsWith('.roster.last-roster')) rosterFile = path.join(GROKBOT_DIR, name);
|
|
1537
|
-
else {
|
|
1538
|
-
const m = key.match(/\.transcript\.replicas\.([0-9a-f-]{36})$/);
|
|
1539
|
-
if (m) transcripts.set(m[1], path.join(GROKBOT_DIR, name));
|
|
1540
|
-
}
|
|
1541
|
-
}
|
|
1542
|
-
const rows = rosterFile ? readJson(rosterFile)?.value?.rows : null;
|
|
1543
|
-
if (!Array.isArray(rows)) return sessions;
|
|
1544
|
-
|
|
1545
|
-
for (const row of rows) {
|
|
1546
|
-
// A Bot is a standing teammate rather than a session that ends, so the
|
|
1547
|
-
// roster is mirrored as it stands: what the Grok Bot sidebar lists, the
|
|
1548
|
-
// island shows.
|
|
1549
|
-
if (!row?.id || row.isHiddenFromSidebar) continue;
|
|
1550
|
-
const file = transcripts.get(row.id) || null;
|
|
1551
|
-
const scan = file
|
|
1552
|
-
? memoScan(`grokbot:${file}`, file, () => {
|
|
1553
|
-
const entries = grokbotEntriesOf(file);
|
|
1554
|
-
return { messages: grokbotMessages(entries, 1), streaming: entries.some((e) => e?.isStreaming === true) };
|
|
1555
|
-
})
|
|
1556
|
-
: { messages: [], streaming: false };
|
|
1557
|
-
let updatedAt = row.lastActivityAt || row.updatedAt || 0;
|
|
1558
|
-
if (file) { try { updatedAt = Math.max(updatedAt, fs.statSync(file).mtimeMs); } catch {} }
|
|
1559
|
-
const busy = scan.streaming && Date.now() - updatedAt < GROKBOT_BUSY_STALE_MS;
|
|
1560
|
-
if (file) transcriptCache.set(row.id, file);
|
|
1561
|
-
sessions.push({
|
|
1562
|
-
id: row.id,
|
|
1563
|
-
provider: 'grokbot',
|
|
1564
|
-
name: row.name || 'Bot',
|
|
1565
|
-
// Its working directory is on a machine that is not this one, and
|
|
1566
|
-
// claiming otherwise would put a path on the island you cannot open.
|
|
1567
|
-
cwd: '',
|
|
1568
|
-
project: 'Grok Bot',
|
|
1569
|
-
// awaitingUserResponse is how the app marks a Bot stopped on you, which
|
|
1570
|
-
// is the same pink invader as a held permission request.
|
|
1571
|
-
status: row.awaitingUserResponse ? 'waiting' : (busy ? 'busy' : 'idle'),
|
|
1572
|
-
updatedAt,
|
|
1573
|
-
startedAt: row.createdAt || 0,
|
|
1574
|
-
lastMessage: scan.messages.length ? scan.messages[scan.messages.length - 1] : null,
|
|
1575
|
-
tty: '',
|
|
1576
|
-
live: false,
|
|
1577
|
-
// Nothing reads this yet. It is what a build that greys out the compose
|
|
1578
|
-
// box will key on, instead of re-deriving read-only from the provider.
|
|
1579
|
-
readOnly: true,
|
|
1580
|
-
});
|
|
1581
|
-
}
|
|
1582
|
-
return sessions;
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
let grokbotCache = { at: 0, sessions: [] };
|
|
1586
|
-
function readGrokBotSessions() {
|
|
1587
|
-
if (Date.now() - grokbotCache.at < 10_000) return grokbotCache.sessions;
|
|
1588
|
-
grokbotCache.at = Date.now();
|
|
1589
|
-
grokbotCache.sessions = scanGrokBotSessions();
|
|
1590
|
-
return grokbotCache.sessions;
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
1464
|
/// The phone-facing shape of a held request.
|
|
1594
1465
|
const askPayload = (ask, queued) => ({
|
|
1595
1466
|
id: ask.id,
|
|
@@ -1601,7 +1472,7 @@ const askPayload = (ask, queued) => ({
|
|
|
1601
1472
|
});
|
|
1602
1473
|
|
|
1603
1474
|
function readSessions() {
|
|
1604
|
-
const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()
|
|
1475
|
+
const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
|
|
1605
1476
|
const known = new Set(sessions.map((s) => s.id));
|
|
1606
1477
|
for (const s of sessions) {
|
|
1607
1478
|
const queue = askQueue.get(s.id);
|
|
@@ -1640,7 +1511,20 @@ function readSessions() {
|
|
|
1640
1511
|
}
|
|
1641
1512
|
|
|
1642
1513
|
sessions.sort((a, b) => a.startedAt - b.startedAt);
|
|
1643
|
-
|
|
1514
|
+
// One row per id, whatever the providers hand over.
|
|
1515
|
+
//
|
|
1516
|
+
// The phone keys every pilot on the session id: the crew list draws a
|
|
1517
|
+
// ForEach over it and the island keeps one sprite per id. Two rows sharing
|
|
1518
|
+
// one id do not draw twice, they draw once and leave a hole the height of a
|
|
1519
|
+
// card where the other should be, which reads as a layout bug rather than
|
|
1520
|
+
// as bad data. Codex was the source of that once already; this is here so
|
|
1521
|
+
// the next provider that repeats an id cannot do it again.
|
|
1522
|
+
const byId = new Map();
|
|
1523
|
+
for (const s of sessions) {
|
|
1524
|
+
const seen = byId.get(s.id);
|
|
1525
|
+
if (!seen || (s.updatedAt || 0) > (seen.updatedAt || 0)) byId.set(s.id, s);
|
|
1526
|
+
}
|
|
1527
|
+
return byId.size === sessions.length ? sessions : sessions.filter((s) => byId.get(s.id) === s);
|
|
1644
1528
|
}
|
|
1645
1529
|
|
|
1646
1530
|
// ---------- remote approvals: runtime ----------
|
|
@@ -1673,6 +1557,10 @@ function hostState() {
|
|
|
1673
1557
|
settingsOk: settings !== null,
|
|
1674
1558
|
socketOk: approvalServerUp,
|
|
1675
1559
|
},
|
|
1560
|
+
// Which agents the phone may start here. An empty list is a real answer
|
|
1561
|
+
// (nothing launchable found); the whole field missing is an older daemon,
|
|
1562
|
+
// and the app hides the button rather than offering one that cannot work.
|
|
1563
|
+
launch: { providers: launchProviders() },
|
|
1676
1564
|
updateAvailable: availableUpdate,
|
|
1677
1565
|
};
|
|
1678
1566
|
hostCache = { at: Date.now(), value };
|
|
@@ -1817,6 +1705,9 @@ function onPermissionRequest(req, socket) {
|
|
|
1817
1705
|
at: Date.now(),
|
|
1818
1706
|
socket,
|
|
1819
1707
|
done: false,
|
|
1708
|
+
// Set once Claude Code has actually reported this session as waiting, so
|
|
1709
|
+
// releaseAnsweredOnMac knows there was a prompt on the Mac to answer.
|
|
1710
|
+
sawWaiting: false,
|
|
1820
1711
|
};
|
|
1821
1712
|
ask.holdTimer = setTimeout(
|
|
1822
1713
|
() => settleAsk(ask, null, 'released', 'held too long, prompting on the Mac instead'),
|
|
@@ -1901,10 +1792,30 @@ const ANSWERED_GRACE_MS = 6000;
|
|
|
1901
1792
|
function releaseAnsweredOnMac() {
|
|
1902
1793
|
if (!pendingAsks.size) return;
|
|
1903
1794
|
for (const ask of [...pendingAsks.values()]) {
|
|
1904
|
-
if (Date.now() - ask.at < ANSWERED_GRACE_MS) continue;
|
|
1905
1795
|
const status = lastRawStatus.get(ask.sessionId);
|
|
1906
1796
|
// Unknown session: nothing to conclude, leave it to the socket or the cap.
|
|
1907
|
-
if (!status
|
|
1797
|
+
if (!status) continue;
|
|
1798
|
+
// Recorded before the grace period is checked, not after: the prompt is
|
|
1799
|
+
// most likely to be showing as waiting in exactly those first seconds.
|
|
1800
|
+
if (status === 'waiting') { ask.sawWaiting = true; continue; }
|
|
1801
|
+
if (Date.now() - ask.at < ANSWERED_GRACE_MS) continue;
|
|
1802
|
+
// Not waiting is only evidence of an answer if we watched the prompt go
|
|
1803
|
+
// up in the first place.
|
|
1804
|
+
//
|
|
1805
|
+
// Claude Code writes "waiting" for the prompt it draws itself, and a
|
|
1806
|
+
// permission request raised from inside a running turn never gets one:
|
|
1807
|
+
// the session sits on "busy" for as long as the hook blocks. Concluding
|
|
1808
|
+
// from that alone declared every gated command inside a turn answered six
|
|
1809
|
+
// seconds after it was asked, so the card vanished off the phone mid tap
|
|
1810
|
+
// and the session stayed stopped on a question nobody could answer from
|
|
1811
|
+
// there any more.
|
|
1812
|
+
//
|
|
1813
|
+
// Idle is the signal that is safe to read instead. A session cannot go
|
|
1814
|
+
// idle while a hook is blocking a tool call, so idle means the turn ran
|
|
1815
|
+
// to the end and this request is long settled, whatever settled it. Busy
|
|
1816
|
+
// and shell mean the turn is still in flight, which is exactly when the
|
|
1817
|
+
// card has to stay put.
|
|
1818
|
+
if (!ask.sawWaiting && (status === 'busy' || status === 'shell')) continue;
|
|
1908
1819
|
settleAsk(ask, null, 'gone', 'answered on the Mac');
|
|
1909
1820
|
}
|
|
1910
1821
|
}
|
|
@@ -2036,14 +1947,8 @@ function watchTranscript(sessionId) {
|
|
|
2036
1947
|
if (!file) return;
|
|
2037
1948
|
let debounce;
|
|
2038
1949
|
try {
|
|
2039
|
-
// Journals are appended to, so watching the file is enough.
|
|
2040
|
-
|
|
2041
|
-
// bound to an inode nothing writes to again: watch the directory instead
|
|
2042
|
-
// and filter, which survives the swap.
|
|
2043
|
-
const inGrokBot = file.startsWith(GROKBOT_DIR + path.sep);
|
|
2044
|
-
const target = inGrokBot ? GROKBOT_DIR : file;
|
|
2045
|
-
const w = fs.watch(target, (_ev, changed) => {
|
|
2046
|
-
if (inGrokBot && changed && changed !== path.basename(file)) return;
|
|
1950
|
+
// Journals are appended to, so watching the file is enough.
|
|
1951
|
+
const w = fs.watch(file, () => {
|
|
2047
1952
|
clearTimeout(debounce);
|
|
2048
1953
|
debounce = setTimeout(() => {
|
|
2049
1954
|
ws?.send(seal({ type: 'transcript', sessionId, messages: readMessages(sessionId) }));
|
|
@@ -2106,6 +2011,14 @@ async function handleCommand(cmd) {
|
|
|
2106
2011
|
settleAsk(ask, { behavior: 'deny', message: reason || 'Denied from the Ground Control app.' },
|
|
2107
2012
|
'denied', reason ? `from the phone: ${reason.slice(0, 60)}` : 'from the phone');
|
|
2108
2013
|
}
|
|
2014
|
+
} else if (cmd.type === 'projects') {
|
|
2015
|
+
ws?.send(seal({
|
|
2016
|
+
type: 'projects',
|
|
2017
|
+
projects: knownProjects().map((p) => ({ path: p.path, name: p.name, at: p.at })),
|
|
2018
|
+
providers: launchProviders(),
|
|
2019
|
+
}));
|
|
2020
|
+
} else if (cmd.type === 'launch') {
|
|
2021
|
+
await launchSession(cmd);
|
|
2109
2022
|
} else if (cmd.type === 'ping' && cmd.sessionId) {
|
|
2110
2023
|
const s = readSessions().find((x) => x.id === cmd.sessionId);
|
|
2111
2024
|
ws?.send(seal({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() }));
|
|
@@ -2113,15 +2026,6 @@ async function handleCommand(cmd) {
|
|
|
2113
2026
|
const session = readSessions().find((s) => s.id === cmd.sessionId);
|
|
2114
2027
|
if (!session) return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' }));
|
|
2115
2028
|
|
|
2116
|
-
// A Bot lives on a cloud VM this daemon has no channel to. Saying so beats
|
|
2117
|
-
// a send that looks accepted and arrives nowhere.
|
|
2118
|
-
if (session.provider === 'grokbot') {
|
|
2119
|
-
return ws?.send(seal({
|
|
2120
|
-
type: 'promptResult', sessionId: cmd.sessionId, ok: false,
|
|
2121
|
-
error: 'Grok Bot runs in the cloud, so Ground Control can only watch it. Open the Grok Bot app to reply.',
|
|
2122
|
-
}));
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
2029
|
if (session.provider && session.provider !== 'claude') return promptHeadless(session, cmd.text);
|
|
2126
2030
|
|
|
2127
2031
|
// Preferred path: type into the real terminal so the desktop updates live.
|
|
@@ -2201,7 +2105,6 @@ const headlessPromptSpec = {
|
|
|
2201
2105
|
function bustProviderCaches() {
|
|
2202
2106
|
codexCache.at = 0;
|
|
2203
2107
|
kimiCache.at = 0;
|
|
2204
|
-
grokbotCache.at = 0;
|
|
2205
2108
|
}
|
|
2206
2109
|
|
|
2207
2110
|
function promptHeadless(session, text) {
|
|
@@ -2258,6 +2161,166 @@ function promptHeadless(session, text) {
|
|
|
2258
2161
|
watchTranscript(session.id);
|
|
2259
2162
|
}
|
|
2260
2163
|
|
|
2164
|
+
// ---------- launching a new pilot ----------
|
|
2165
|
+
// Everything above answers a session that already exists. This starts one.
|
|
2166
|
+
//
|
|
2167
|
+
// The phone never sends a path. It picks from a list this daemon built out of
|
|
2168
|
+
// what is already on disk, and the launch validates the choice against that
|
|
2169
|
+
// same list before running anything, so the worst a stolen channel can do is
|
|
2170
|
+
// open a terminal in a folder you have already worked in today.
|
|
2171
|
+
|
|
2172
|
+
const PROJECT_LIMIT = 40;
|
|
2173
|
+
/// A folder nobody has touched in three months is not what you meant to open
|
|
2174
|
+
/// from your phone, and the list has to fit on a phone screen.
|
|
2175
|
+
const PROJECT_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
|
|
2176
|
+
|
|
2177
|
+
/// The real working directory behind one of Claude Code's project folders.
|
|
2178
|
+
///
|
|
2179
|
+
/// The folder name is the path with every slash turned into a dash, which
|
|
2180
|
+
/// cannot be undone: a directory with a dash in its own name decodes to
|
|
2181
|
+
/// something that was never there. The transcripts inside record the actual
|
|
2182
|
+
/// cwd, so read it rather than guess it.
|
|
2183
|
+
function projectCwd(dir) {
|
|
2184
|
+
let files = [];
|
|
2185
|
+
try {
|
|
2186
|
+
files = fs.readdirSync(dir)
|
|
2187
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
2188
|
+
.map((f) => {
|
|
2189
|
+
const p = path.join(dir, f);
|
|
2190
|
+
try { return { p, at: fs.statSync(p).mtimeMs }; } catch { return null; }
|
|
2191
|
+
})
|
|
2192
|
+
.filter(Boolean)
|
|
2193
|
+
.sort((a, b) => b.at - a.at);
|
|
2194
|
+
} catch { return null; }
|
|
2195
|
+
if (!files.length) return null;
|
|
2196
|
+
|
|
2197
|
+
for (const { p, at } of files.slice(0, 3)) {
|
|
2198
|
+
let head = '';
|
|
2199
|
+
try {
|
|
2200
|
+
const fd = fs.openSync(p, 'r');
|
|
2201
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
2202
|
+
const read = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
2203
|
+
fs.closeSync(fd);
|
|
2204
|
+
head = buf.subarray(0, read).toString('utf8');
|
|
2205
|
+
} catch { continue; }
|
|
2206
|
+
for (const line of head.split('\n')) {
|
|
2207
|
+
if (!line.trim()) continue;
|
|
2208
|
+
let e; try { e = JSON.parse(line); } catch { continue; }
|
|
2209
|
+
if (typeof e.cwd === 'string' && e.cwd) return { cwd: e.cwd, at };
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
return null;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
let projectsCache = { at: 0, value: [] };
|
|
2216
|
+
function knownProjects() {
|
|
2217
|
+
if (Date.now() - projectsCache.at < 60_000) return projectsCache.value;
|
|
2218
|
+
const byPath = new Map();
|
|
2219
|
+
const note = (cwd, at) => {
|
|
2220
|
+
if (!cwd || !fs.existsSync(cwd)) return;
|
|
2221
|
+
const prev = byPath.get(cwd);
|
|
2222
|
+
if (!prev || prev.at < at) byPath.set(cwd, { path: cwd, name: path.basename(cwd) || cwd, at });
|
|
2223
|
+
};
|
|
2224
|
+
|
|
2225
|
+
let dirs = [];
|
|
2226
|
+
try { dirs = fs.readdirSync(PROJECTS_DIR); } catch {}
|
|
2227
|
+
for (const d of dirs) {
|
|
2228
|
+
const found = projectCwd(path.join(PROJECTS_DIR, d));
|
|
2229
|
+
if (found) note(found.cwd, found.at);
|
|
2230
|
+
}
|
|
2231
|
+
// Whatever is flying right now, even if its transcript has not landed yet.
|
|
2232
|
+
for (const s of readSessions()) note(s.cwd, Date.now());
|
|
2233
|
+
|
|
2234
|
+
const cutoff = Date.now() - PROJECT_MAX_AGE_MS;
|
|
2235
|
+
const value = [...byPath.values()]
|
|
2236
|
+
.filter((p) => p.at >= cutoff)
|
|
2237
|
+
.sort((a, b) => b.at - a.at)
|
|
2238
|
+
.slice(0, PROJECT_LIMIT);
|
|
2239
|
+
projectsCache = { at: Date.now(), value };
|
|
2240
|
+
return value;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/// Which agents this Mac can start from the phone.
|
|
2244
|
+
///
|
|
2245
|
+
/// Claude Code only, on purpose. Every provider here has to be one whose bare
|
|
2246
|
+
/// command opens an interactive session that then shows up on the island, and
|
|
2247
|
+
/// claude is the one that has been checked. Codex and the kimi CLIs are read
|
|
2248
|
+
/// and written by the daemon already, but nobody has watched either of them
|
|
2249
|
+
/// come up this way, so offering them would be a button that might do nothing.
|
|
2250
|
+
function launchProviders() {
|
|
2251
|
+
return claudeBin() ? ['claude'] : [];
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/// Single quotes for the shell, then AppleScript quoting on top. Both layers
|
|
2255
|
+
/// matter: the directory comes from disk and the first order comes from your
|
|
2256
|
+
/// thumbs.
|
|
2257
|
+
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
2258
|
+
|
|
2259
|
+
let lastLaunchAt = 0;
|
|
2260
|
+
|
|
2261
|
+
async function launchSession(cmd) {
|
|
2262
|
+
const fail = (error) => ws?.send(seal({ type: 'launchResult', ok: false, error }));
|
|
2263
|
+
|
|
2264
|
+
const providers = launchProviders();
|
|
2265
|
+
const provider = cmd.provider || 'claude';
|
|
2266
|
+
if (!providers.includes(provider)) {
|
|
2267
|
+
return fail(`This Mac cannot start a ${provider} session from the phone. Ground Control could not find the ${provider} command on your PATH.`);
|
|
2268
|
+
}
|
|
2269
|
+
const target = knownProjects().find((p) => p.path === cmd.cwd);
|
|
2270
|
+
if (!target) {
|
|
2271
|
+
// The list is a minute old at most, so this is almost always a folder that
|
|
2272
|
+
// was renamed or deleted since it was drawn.
|
|
2273
|
+
return fail('That folder is not on this Mac any more. Pull the list again.');
|
|
2274
|
+
}
|
|
2275
|
+
if (Date.now() - lastLaunchAt < 5000) {
|
|
2276
|
+
return fail('One at a time. Give the last launch a few seconds.');
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
const apps = terminalApps();
|
|
2280
|
+
const first = (cmd.text || '').trim();
|
|
2281
|
+
// Claude Code takes an opening prompt as a positional argument and starts the
|
|
2282
|
+
// normal interactive session with it already submitted, which beats waiting a
|
|
2283
|
+
// guessed number of seconds for a REPL to boot and then typing at it.
|
|
2284
|
+
const line = `cd ${shq(target.path)} && ${provider}${first ? ` ${shq(first)}` : ''}`;
|
|
2285
|
+
|
|
2286
|
+
lastLaunchAt = Date.now();
|
|
2287
|
+
let opened = null;
|
|
2288
|
+
if (apps.iterm) {
|
|
2289
|
+
const r = await osascript(`tell application "iTerm2"
|
|
2290
|
+
activate
|
|
2291
|
+
set w to (create window with default profile)
|
|
2292
|
+
tell current session of w to write text "${escapeAS(line)}"
|
|
2293
|
+
return "ok"
|
|
2294
|
+
end tell`);
|
|
2295
|
+
if (r === 'ok') opened = 'iTerm2';
|
|
2296
|
+
}
|
|
2297
|
+
if (!opened) {
|
|
2298
|
+
// Terminal.app is on every Mac, and telling it to do a script launches it
|
|
2299
|
+
// if it was closed, so this is the path that always exists.
|
|
2300
|
+
const r = await osascript(`tell application "Terminal"
|
|
2301
|
+
activate
|
|
2302
|
+
do script "${escapeAS(line)}"
|
|
2303
|
+
return "ok"
|
|
2304
|
+
end tell`);
|
|
2305
|
+
if (r === 'ok') opened = 'Terminal';
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
if (!opened) {
|
|
2309
|
+
lastLaunchAt = 0;
|
|
2310
|
+
if (automationDenied) {
|
|
2311
|
+
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.');
|
|
2312
|
+
}
|
|
2313
|
+
return fail('Could not open a terminal window on your Mac.');
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
console.log(`[daemon] launched ${provider} in ${target.path} via ${opened}`);
|
|
2317
|
+
// The new session reaches the island through the same discovery every other
|
|
2318
|
+
// one does, which takes a few seconds. Say so rather than implying the pilot
|
|
2319
|
+
// is already there.
|
|
2320
|
+
ws?.send(seal({ type: 'launchResult', ok: true, cwd: target.path, note: `${provider} is starting in ${target.name}` }));
|
|
2321
|
+
sendSnapshot(true);
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2261
2324
|
let lastPongAt = Date.now();
|
|
2262
2325
|
|
|
2263
2326
|
function connect() {
|
|
@@ -2359,4 +2422,3 @@ function watchProviderDir(dir, cache, label) {
|
|
|
2359
2422
|
watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin() || 'not found yet, rechecked on each prompt'})`);
|
|
2360
2423
|
watchProviderDir(path.join(KIMI_DIR, 'sessions'), kimiCache, 'kimi sessions');
|
|
2361
2424
|
watchProviderDir(path.join(KIMICODE_DIR, 'sessions'), kimiCache, 'kimi code sessions');
|
|
2362
|
-
watchProviderDir(GROKBOT_DIR, grokbotCache, 'grok bot roster (read only, replies happen in the Grok Bot app)');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "See and command your coding agents from your iPhone. Pairs the Ground Control app with Claude Code, Codex
|
|
3
|
+
"version": "0.1.11",
|
|
4
|
+
"description": "See and command your coding agents from your iPhone. Pairs the Ground Control app with Claude Code, Codex and Kimi sessions running on this Mac, end to end encrypted.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"groundcontrol": "bin/groundcontrol.mjs"
|
|
7
7
|
},
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
"claude-code",
|
|
22
22
|
"codex",
|
|
23
23
|
"kimi",
|
|
24
|
-
"grok",
|
|
25
24
|
"ai",
|
|
26
25
|
"agents",
|
|
27
26
|
"cli",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// A held permission request must clear when it is answered on the Mac, and
|
|
2
|
+
// must NOT clear while it is still genuinely waiting on you. That balance has
|
|
3
|
+
// been wrong twice, in both directions, so it gets a test.
|
|
4
|
+
//
|
|
5
|
+
// The fast path is the hook's socket closing, and in practice it almost never
|
|
6
|
+
// fires: 182 of 189 settlements in a real daemon log came from this function
|
|
7
|
+
// instead. So the status reading is the mechanism, not the safety net.
|
|
8
|
+
//
|
|
9
|
+
// Wrong the first way: any status other than "waiting" counted as answered.
|
|
10
|
+
// Claude Code only writes "waiting" for the prompt it draws itself, and a
|
|
11
|
+
// permission request raised from inside a running turn never gets one, so
|
|
12
|
+
// every gated command inside a turn was declared answered six seconds after
|
|
13
|
+
// it was asked. The card vanished off the phone mid tap and the session
|
|
14
|
+
// stayed stopped on a question nobody could answer from there.
|
|
15
|
+
//
|
|
16
|
+
// Wrong the second way: requiring "waiting" to have been seen at all. That is
|
|
17
|
+
// correct and it never lets go, so a request genuinely answered on the Mac
|
|
18
|
+
// leaves a phantom card until the twelve hour hold cap.
|
|
19
|
+
//
|
|
20
|
+
// Right: hold through busy and shell, let go on idle. A session cannot report
|
|
21
|
+
// idle while a hook is blocking a tool call.
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
|
|
27
|
+
const src = fs.readFileSync(DAEMON, 'utf8');
|
|
28
|
+
const start = src.indexOf('const ANSWERED_GRACE_MS');
|
|
29
|
+
const end = src.indexOf('/// Sitting back down at the Mac clears the phone card.');
|
|
30
|
+
if (start < 0 || end < 0 || end < start) throw new Error('could not locate releaseAnsweredOnMac in daemon.mjs');
|
|
31
|
+
|
|
32
|
+
let failures = 0;
|
|
33
|
+
const check = (label, cond, detail = '') => {
|
|
34
|
+
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
35
|
+
if (!cond) failures++;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const settled = [];
|
|
39
|
+
const pendingAsks = new Map();
|
|
40
|
+
const lastRawStatus = new Map();
|
|
41
|
+
const settleAsk = (ask, _decision, outcome, note) => {
|
|
42
|
+
pendingAsks.delete(ask.id);
|
|
43
|
+
settled.push({ id: ask.id, outcome, note });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const { releaseAnsweredOnMac, ANSWERED_GRACE_MS } = new Function(
|
|
47
|
+
'pendingAsks', 'lastRawStatus', 'settleAsk',
|
|
48
|
+
`${src.slice(start, end)}\n return { releaseAnsweredOnMac, ANSWERED_GRACE_MS };`,
|
|
49
|
+
)(pendingAsks, lastRawStatus, settleAsk);
|
|
50
|
+
|
|
51
|
+
/// A held request, aged so the grace period is behind it unless asked otherwise.
|
|
52
|
+
function hold(id, sessionId, { ageMs = ANSWERED_GRACE_MS + 1000, sawWaiting = false } = {}) {
|
|
53
|
+
const ask = { id, sessionId, at: Date.now() - ageMs, sawWaiting };
|
|
54
|
+
pendingAsks.set(id, ask);
|
|
55
|
+
return ask;
|
|
56
|
+
}
|
|
57
|
+
const reset = () => { pendingAsks.clear(); lastRawStatus.clear(); settled.length = 0; };
|
|
58
|
+
const outcomeOf = (id) => settled.find((s) => s.id === id)?.outcome;
|
|
59
|
+
|
|
60
|
+
// The bug Carlos hit: a gated command inside a running turn. Claude never says
|
|
61
|
+
// waiting, it says busy for as long as the hook blocks.
|
|
62
|
+
reset();
|
|
63
|
+
hold('a1', 's-busy');
|
|
64
|
+
lastRawStatus.set('s-busy', 'busy');
|
|
65
|
+
releaseAnsweredOnMac();
|
|
66
|
+
check('a request held inside a running turn is not declared answered', pendingAsks.has('a1'), outcomeOf('a1') || 'still held');
|
|
67
|
+
|
|
68
|
+
// Same, for a session running a shell command.
|
|
69
|
+
reset();
|
|
70
|
+
hold('a2', 's-shell');
|
|
71
|
+
lastRawStatus.set('s-shell', 'shell');
|
|
72
|
+
releaseAnsweredOnMac();
|
|
73
|
+
check('nor one held while a shell command runs', pendingAsks.has('a2'));
|
|
74
|
+
|
|
75
|
+
// The other failure mode: the turn ended, so whatever was holding it is gone.
|
|
76
|
+
reset();
|
|
77
|
+
hold('a3', 's-idle');
|
|
78
|
+
lastRawStatus.set('s-idle', 'idle');
|
|
79
|
+
releaseAnsweredOnMac();
|
|
80
|
+
check('an idle session releases the request', outcomeOf('a3') === 'gone', outcomeOf('a3') || 'still held');
|
|
81
|
+
|
|
82
|
+
// A busy session that later goes idle has to release, or the card is phantom
|
|
83
|
+
// until the twelve hour cap.
|
|
84
|
+
reset();
|
|
85
|
+
const a4 = hold('a4', 's-turn');
|
|
86
|
+
lastRawStatus.set('s-turn', 'busy');
|
|
87
|
+
releaseAnsweredOnMac();
|
|
88
|
+
const heldDuringTurn = pendingAsks.has('a4');
|
|
89
|
+
lastRawStatus.set('s-turn', 'idle');
|
|
90
|
+
releaseAnsweredOnMac();
|
|
91
|
+
check('held through the turn, released when it ends', heldDuringTurn && outcomeOf('a4') === 'gone');
|
|
92
|
+
check('the ask is not left in the pending map', !pendingAsks.has('a4') && a4.id === 'a4');
|
|
93
|
+
|
|
94
|
+
// Claude's own prompt does write waiting, and dropping out of it is the
|
|
95
|
+
// original signal, which still has to work.
|
|
96
|
+
reset();
|
|
97
|
+
hold('a5', 's-prompt');
|
|
98
|
+
lastRawStatus.set('s-prompt', 'waiting');
|
|
99
|
+
releaseAnsweredOnMac();
|
|
100
|
+
const heldWhileWaiting = pendingAsks.has('a5');
|
|
101
|
+
lastRawStatus.set('s-prompt', 'busy');
|
|
102
|
+
releaseAnsweredOnMac();
|
|
103
|
+
check('a prompt seen waiting releases as soon as it stops waiting',
|
|
104
|
+
heldWhileWaiting && outcomeOf('a5') === 'gone', outcomeOf('a5') || 'still held');
|
|
105
|
+
|
|
106
|
+
// The grace period covers the gap between the hook firing and Claude Code
|
|
107
|
+
// getting round to writing its status.
|
|
108
|
+
reset();
|
|
109
|
+
hold('a6', 's-fresh', { ageMs: 1000 });
|
|
110
|
+
lastRawStatus.set('s-fresh', 'idle');
|
|
111
|
+
releaseAnsweredOnMac();
|
|
112
|
+
check('a request younger than the grace period is left alone', pendingAsks.has('a6'));
|
|
113
|
+
|
|
114
|
+
// A session the daemon cannot see at all is not evidence of anything.
|
|
115
|
+
reset();
|
|
116
|
+
hold('a7', 's-unknown');
|
|
117
|
+
releaseAnsweredOnMac();
|
|
118
|
+
check('an unknown session releases nothing', pendingAsks.has('a7'));
|
|
119
|
+
|
|
120
|
+
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
|
121
|
+
process.exit(failures ? 1 : 0);
|
package/test/codex.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Codex writes one rollout file per run, and two of the ways it does that used
|
|
2
|
+
// to put holes in the app.
|
|
3
|
+
//
|
|
4
|
+
// A helper thread (the review subagent) gets its own rollout carrying the
|
|
5
|
+
// *parent's* session_id, so the daemon reported two sessions under one id. The
|
|
6
|
+
// phone keys every pilot on that id, so it drew one and left a card sized gap
|
|
7
|
+
// where the other should have been, on the crew list and on the island both.
|
|
8
|
+
//
|
|
9
|
+
// And a plain `codex exec` starts a fresh rollout every time, so asking Codex
|
|
10
|
+
// to review something three times in one folder put three identical pilots on
|
|
11
|
+
// the island. Only the freshest one in a directory is worth talking to.
|
|
12
|
+
//
|
|
13
|
+
// This runs the real reader out of daemon.mjs against a synthetic ~/.codex.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
|
|
20
|
+
const src = fs.readFileSync(DAEMON, 'utf8');
|
|
21
|
+
const start = src.indexOf('// ---------- Codex session discovery ----------');
|
|
22
|
+
const end = src.indexOf('// ---------- Kimi session discovery ----------');
|
|
23
|
+
if (start < 0 || end < 0 || end < start) throw new Error('could not locate the Codex block in daemon.mjs');
|
|
24
|
+
|
|
25
|
+
let failures = 0;
|
|
26
|
+
const check = (label, cond, detail = '') => {
|
|
27
|
+
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
28
|
+
if (!cond) failures++;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-codex-'));
|
|
32
|
+
const day = path.join(tmp, 'sessions', '2026', '09', '05');
|
|
33
|
+
fs.mkdirSync(day, { recursive: true });
|
|
34
|
+
|
|
35
|
+
/// One rollout: the session_meta line, then a finished turn so the reader has
|
|
36
|
+
/// something to call a last message.
|
|
37
|
+
function rollout(name, meta, { text = 'hello', minutesAgo = 1 } = {}) {
|
|
38
|
+
const file = path.join(day, `rollout-${name}.jsonl`);
|
|
39
|
+
const at = new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
|
40
|
+
fs.writeFileSync(file, [
|
|
41
|
+
JSON.stringify({ timestamp: at, type: 'session_meta', payload: { timestamp: at, ...meta } }),
|
|
42
|
+
JSON.stringify({ timestamp: at, type: 'event_msg', payload: { type: 'task_started' } }),
|
|
43
|
+
JSON.stringify({ timestamp: at, type: 'event_msg', payload: { type: 'agent_message', message: text } }),
|
|
44
|
+
JSON.stringify({ timestamp: at, type: 'event_msg', payload: { type: 'task_complete' } }),
|
|
45
|
+
'',
|
|
46
|
+
].join('\n'));
|
|
47
|
+
const when = Date.now() - minutesAgo * 60_000;
|
|
48
|
+
fs.utimesSync(file, when / 1000, when / 1000);
|
|
49
|
+
return file;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PROJECT = '/tmp/project-one';
|
|
53
|
+
const OTHER = '/tmp/project-two';
|
|
54
|
+
|
|
55
|
+
// The pair that broke it: a run and the review helper it spawned, same second,
|
|
56
|
+
// same id, two files.
|
|
57
|
+
rollout('a-parent', { session_id: 'id-parent', id: 'id-parent', cwd: PROJECT, thread_source: 'user', source: 'exec' },
|
|
58
|
+
{ text: 'the older run', minutesAgo: 9 });
|
|
59
|
+
rollout('a-helper', { session_id: 'id-parent', id: 'id-child', parent_thread_id: 'id-parent', cwd: PROJECT, thread_source: 'subagent', source: { subagent: 'review' } },
|
|
60
|
+
{ text: 'reviewing', minutesAgo: 8 });
|
|
61
|
+
// A second, unrelated run in the same folder, more recent than both.
|
|
62
|
+
rollout('b-newer', { session_id: 'id-newer', id: 'id-newer', cwd: PROJECT, thread_source: 'user', source: 'exec' },
|
|
63
|
+
{ text: 'the newest run', minutesAgo: 2 });
|
|
64
|
+
// A different folder, which must survive on its own.
|
|
65
|
+
rollout('c-other', { session_id: 'id-other', id: 'id-other', cwd: OTHER, thread_source: 'user', source: 'exec' },
|
|
66
|
+
{ text: 'elsewhere', minutesAgo: 3 });
|
|
67
|
+
// Older than the active window: gone from the fleet entirely.
|
|
68
|
+
rollout('d-stale', { session_id: 'id-stale', id: 'id-stale', cwd: '/tmp/project-three', thread_source: 'user', source: 'exec' },
|
|
69
|
+
{ text: 'yesterday', minutesAgo: 60 * 5 });
|
|
70
|
+
|
|
71
|
+
// tailLines, memoScan and scanMemo all live inside the extracted block, so the
|
|
72
|
+
// only things handed in are the modules and the two maps the daemon shares.
|
|
73
|
+
const block = src.slice(start, end)
|
|
74
|
+
.replace(/const CODEX_DIR = .*/, `const CODEX_DIR = ${JSON.stringify(tmp)};`);
|
|
75
|
+
|
|
76
|
+
const { readCodexSessions } = new Function('fs', 'os', 'path', 'transcriptCache', 'headlessActive',
|
|
77
|
+
`${block}\n return { readCodexSessions };`)(fs, os, path, new Map(), new Set());
|
|
78
|
+
|
|
79
|
+
const sessions = readCodexSessions();
|
|
80
|
+
const ids = sessions.map((s) => s.id);
|
|
81
|
+
const cwds = sessions.map((s) => s.cwd);
|
|
82
|
+
|
|
83
|
+
check('the helper thread is not a pilot', !ids.includes('id-child') && ids.filter((i) => i === 'id-parent').length <= 1, ids.join(', '));
|
|
84
|
+
check('no id appears twice', new Set(ids).size === ids.length, ids.join(', '));
|
|
85
|
+
check('one pilot per folder', new Set(cwds).size === cwds.length, cwds.join(', '));
|
|
86
|
+
check('the folder keeps its freshest run', ids.includes('id-newer') && !ids.includes('id-parent'), ids.join(', '));
|
|
87
|
+
check('a second folder still flies', ids.includes('id-other'));
|
|
88
|
+
check('a stale rollout is off the island', !ids.includes('id-stale'));
|
|
89
|
+
check('the surviving pilot carries its own last message',
|
|
90
|
+
sessions.find((s) => s.id === 'id-newer')?.lastMessage?.text === 'the newest run');
|
|
91
|
+
|
|
92
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
93
|
+
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
|
94
|
+
process.exit(failures ? 1 : 0);
|
package/test/launch.mjs
ADDED
|
@@ -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);
|
package/test/providers.mjs
CHANGED
|
@@ -20,11 +20,14 @@ const check = (label, cond, detail = '') => {
|
|
|
20
20
|
if (!cond) failures++;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
// Find every
|
|
24
|
-
//
|
|
23
|
+
// Find every session object literal and read it, counting braces so a nested
|
|
24
|
+
// object cannot end the block early. Most readers push one straight onto the
|
|
25
|
+
// list; codex builds one first, because it keeps only the freshest rollout per
|
|
26
|
+
// folder and may have to replace a row it already added.
|
|
25
27
|
const blocks = [];
|
|
26
28
|
lines.forEach((line, i) => {
|
|
27
|
-
|
|
29
|
+
const t = line.trim();
|
|
30
|
+
if (!/sessions\.push\(\{\s*$/.test(t) && !/^const session = \{\s*$/.test(t)) return;
|
|
28
31
|
let depth = 1;
|
|
29
32
|
const body = [];
|
|
30
33
|
for (let j = i + 1; j < lines.length && depth > 0; j++) {
|
|
@@ -48,7 +51,7 @@ for (const b of blocks) {
|
|
|
48
51
|
// heard of still renders, uppercased, but a rename would silently drop a brand
|
|
49
52
|
// name that used to be there, so the known set is pinned.
|
|
50
53
|
const providers = new Set(blocks.map((b) => (b.body.match(/provider:\s*'([^']+)'/) || [])[1]).filter(Boolean));
|
|
51
|
-
for (const known of ['claude', 'codex', 'kimi', 'kimicode'
|
|
54
|
+
for (const known of ['claude', 'codex', 'kimi', 'kimicode']) {
|
|
52
55
|
check(`${known} is still a provider the daemon emits`, providers.has(known));
|
|
53
56
|
}
|
|
54
57
|
|
package/test/grokbot.mjs
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// Runs the real Grok Bot reader out of daemon.mjs against whatever replica this
|
|
2
|
-
// Mac has. The block is extracted from source rather than copied, so the test
|
|
3
|
-
// cannot drift from what the daemon ships.
|
|
4
|
-
//
|
|
5
|
-
// The daemon is one long script that connects to the relay on import, so there
|
|
6
|
-
// is nothing to import: pulling the section out by its comment markers and
|
|
7
|
-
// evaluating it with its handful of helpers injected is what keeps this honest.
|
|
8
|
-
//
|
|
9
|
-
// On a Mac without Grok Bot installed the roster assertions are skipped and the
|
|
10
|
-
// machine independent ones still run.
|
|
11
|
-
import fs from 'node:fs';
|
|
12
|
-
import os from 'node:os';
|
|
13
|
-
import path from 'node:path';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
-
|
|
16
|
-
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
|
|
17
|
-
const src = fs.readFileSync(DAEMON, 'utf8');
|
|
18
|
-
const start = src.indexOf('// ---------- Grok Bot session discovery ----------');
|
|
19
|
-
const end = src.indexOf('/// The phone-facing shape of a held request.');
|
|
20
|
-
if (start < 0 || end < 0 || end < start) throw new Error('could not locate the Grok Bot block in daemon.mjs');
|
|
21
|
-
const block = src.slice(start, end);
|
|
22
|
-
|
|
23
|
-
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
24
|
-
const isoOf = (t) => { try { return t ? new Date(t).toISOString() : null; } catch { return null; } };
|
|
25
|
-
const memoScan = (_key, _file, fn) => fn();
|
|
26
|
-
const transcriptCache = new Map();
|
|
27
|
-
|
|
28
|
-
const load = (source, cache) => new Function('fs', 'os', 'path', 'readJson', 'isoOf', 'memoScan', 'transcriptCache',
|
|
29
|
-
`${source}\n return { readGrokBotSessions, readGrokBotMessages, b32decode, GROKBOT_DIR };`)(
|
|
30
|
-
fs, os, path, readJson, isoOf, memoScan, cache);
|
|
31
|
-
|
|
32
|
-
const { readGrokBotSessions, readGrokBotMessages, b32decode, GROKBOT_DIR } = load(block, transcriptCache);
|
|
33
|
-
|
|
34
|
-
let failures = 0;
|
|
35
|
-
const check = (label, cond, detail = '') => {
|
|
36
|
-
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
37
|
-
if (!cond) failures++;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
// A store key the app really writes, so a change to the encoding is caught here
|
|
41
|
-
// rather than by an empty island.
|
|
42
|
-
const known = 'onqw4zbomnwgszlooqxhg3djmnss4y3mnfsw45bnnvsxiyjomfrwg33vnz2c243mn52a';
|
|
43
|
-
check('b32decode reads a real store key', b32decode(known) === 'sand.client.slice.client-meta.account-slot', b32decode(known));
|
|
44
|
-
check('b32decode rejects junk', b32decode('!!!!') === '');
|
|
45
|
-
|
|
46
|
-
// A Mac without Grok Bot must return an empty list rather than throw.
|
|
47
|
-
check('missing install returns empty, does not throw',
|
|
48
|
-
load(block.replace(/const GROKBOT_DIR = .*/, "const GROKBOT_DIR = '/nope/not/here';"), new Map())
|
|
49
|
-
.readGrokBotSessions().length === 0);
|
|
50
|
-
|
|
51
|
-
const sessions = fs.existsSync(GROKBOT_DIR) ? readGrokBotSessions() : [];
|
|
52
|
-
if (!sessions.length) {
|
|
53
|
-
console.log(`\nskipped the roster checks: no Bots found in ${GROKBOT_DIR}`);
|
|
54
|
-
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
|
55
|
-
process.exit(failures ? 1 : 0);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
console.log(`\n${sessions.length} bot(s) found\n`);
|
|
59
|
-
for (const s of sessions) {
|
|
60
|
-
check(`${s.name}: id is a uuid`, /^[0-9a-f-]{36}$/.test(s.id));
|
|
61
|
-
check(`${s.name}: status is one the app understands`, ['idle', 'busy', 'waiting'].includes(s.status));
|
|
62
|
-
check(`${s.name}: has a name`, !!s.name);
|
|
63
|
-
check(`${s.name}: startedAt set`, s.startedAt > 0);
|
|
64
|
-
check(`${s.name}: updatedAt set`, s.updatedAt > 0);
|
|
65
|
-
check(`${s.name}: read only flagged`, s.readOnly === true);
|
|
66
|
-
// Every field the Swift Session struct decodes as non-optional must be here,
|
|
67
|
-
// or the phone drops the whole snapshot, not just this row.
|
|
68
|
-
for (const k of ['id', 'name', 'cwd', 'project', 'status', 'updatedAt', 'startedAt']) {
|
|
69
|
-
check(`${s.name}: ${k} present for Swift decode`, s[k] !== undefined && s[k] !== null);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const file = transcriptCache.get(s.id);
|
|
73
|
-
check(`${s.name}: transcript located`, !!file);
|
|
74
|
-
if (!file) continue;
|
|
75
|
-
const msgs = readGrokBotMessages(file);
|
|
76
|
-
check(`${s.name}: transcript has messages`, msgs.length > 0);
|
|
77
|
-
check(`${s.name}: roles are user/assistant only`, msgs.every((m) => m.role === 'user' || m.role === 'assistant'));
|
|
78
|
-
check(`${s.name}: every message has a timestamp`, msgs.every((m) => !!m.ts));
|
|
79
|
-
check(`${s.name}: lastMessage matches transcript tail`, s.lastMessage?.text === msgs[msgs.length - 1]?.text);
|
|
80
|
-
// The app stores a ProseMirror doc beside every typed turn. Leaking it into
|
|
81
|
-
// the transcript would put raw JSON on the phone.
|
|
82
|
-
check(`${s.name}: no raw richText leaked into text`, msgs.every((m) => !m.text.includes('"type":"doc"')));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
|
86
|
-
process.exit(failures ? 1 : 0);
|