@clastres/groundcontrol 0.1.10 → 0.1.12
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 +344 -230
- package/package.json +2 -3
- package/test/approvals.mjs +121 -0
- package/test/codex.mjs +94 -0
- package/test/providers.mjs +7 -4
- package/test/review-fixes.mjs +294 -0
- package/test/grokbot.mjs +0 -86
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, spawnSync, execFile, execSync } from 'node:child_process';
|
|
18
|
+
import { spawn, spawnSync, execFile, execFileSync, execSync } from 'node:child_process';
|
|
19
19
|
import { promisify } from 'node:util';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { createRequire } from 'node:module';
|
|
22
22
|
|
|
23
23
|
const execFileP = promisify(execFile);
|
|
24
24
|
|
|
25
|
-
const VERSION = '0.1.
|
|
25
|
+
const VERSION = '0.1.12';
|
|
26
26
|
const PACKAGE = '@clastres/groundcontrol';
|
|
27
27
|
|
|
28
28
|
// ---------- config / pairing ----------
|
|
@@ -558,8 +558,41 @@ const pairString = `groundcontrol://pair#${pairPayload}`;
|
|
|
558
558
|
console.log('\n GROUND CONTROL daemon');
|
|
559
559
|
console.log(` relay: ${RELAY} (phone will use: ${relayForPhone})`);
|
|
560
560
|
console.log(` channel: ${CHANNEL}\n`);
|
|
561
|
-
|
|
562
|
-
|
|
561
|
+
// The pairing code and the QR both carry the key itself. They are for a person
|
|
562
|
+
// at a terminal. Anywhere else stdout is a file, the launchd service's is
|
|
563
|
+
// daemon.log, and a log is the thing people paste into a bug report.
|
|
564
|
+
if (subcommand === 'qr' || process.stdout.isTTY) {
|
|
565
|
+
qrcode.generate(pairString, { small: true });
|
|
566
|
+
console.log(`\n Pairing code (paste into the app):\n\n ${pairString}\n`);
|
|
567
|
+
} else {
|
|
568
|
+
console.log(' Pairing code hidden here. Show it with: npx @clastres/groundcontrol qr\n');
|
|
569
|
+
}
|
|
570
|
+
if (runsFromServiceDir) scrubLogSecrets();
|
|
571
|
+
|
|
572
|
+
/// Versions up to 0.1.11 printed the pairing code and its QR into daemon.log
|
|
573
|
+
/// on every start, and the log was readable by every account on the Mac.
|
|
574
|
+
/// Take both out of the existing log and close the permissions. Rewritten in
|
|
575
|
+
/// place rather than replaced: launchd holds the file open for appending, and
|
|
576
|
+
/// a new file would leave it writing into one that no longer exists.
|
|
577
|
+
function scrubLogSecrets() {
|
|
578
|
+
for (const f of [LOG_FILE, path.join(CONF_DIR, 'daemon.err.log')]) {
|
|
579
|
+
try { fs.chmodSync(f, 0o600); } catch {}
|
|
580
|
+
}
|
|
581
|
+
try { fs.chmodSync(CONF_DIR, 0o700); } catch {}
|
|
582
|
+
try {
|
|
583
|
+
const raw = fs.readFileSync(LOG_FILE, 'utf8');
|
|
584
|
+
// A real code, not the [removed] marker left by an earlier pass, so a
|
|
585
|
+
// clean log is read once per start and never rewritten.
|
|
586
|
+
const code = /groundcontrol:\/\/pair#[A-Za-z0-9_-]/;
|
|
587
|
+
const qrRow = /^[█▀▄ ]{20,}$/;
|
|
588
|
+
if (!code.test(raw) && !/^[█▀▄ ]{20,}$/m.test(raw)) return;
|
|
589
|
+
const clean = raw.split('\n')
|
|
590
|
+
.filter((line) => !qrRow.test(line))
|
|
591
|
+
.join('\n')
|
|
592
|
+
.replace(/groundcontrol:\/\/pair#[A-Za-z0-9_-]+/g, 'groundcontrol://pair#[removed]');
|
|
593
|
+
fs.writeFileSync(LOG_FILE, clean);
|
|
594
|
+
} catch {}
|
|
595
|
+
}
|
|
563
596
|
|
|
564
597
|
if (subcommand === 'qr') process.exit(0);
|
|
565
598
|
|
|
@@ -784,10 +817,14 @@ async function injectPrompt(tty, text) {
|
|
|
784
817
|
const clean = text.replace(/\r?\n/g, ' ').trim();
|
|
785
818
|
|
|
786
819
|
if (apps.tmuxPanes.has(dev)) {
|
|
820
|
+
// No shell in between. Phone text used to go through execSync inside
|
|
821
|
+
// double quotes, where backticks and $(...) still run, so "rename `foo`"
|
|
822
|
+
// executed foo on this Mac before the agent ever saw the prompt.
|
|
823
|
+
const pane = apps.tmuxPanes.get(dev);
|
|
787
824
|
try {
|
|
788
|
-
|
|
825
|
+
execFileSync('tmux', ['send-keys', '-t', pane, '-l', '--', clean], { stdio: 'ignore' });
|
|
789
826
|
await sleep(150);
|
|
790
|
-
|
|
827
|
+
execFileSync('tmux', ['send-keys', '-t', pane, 'Enter'], { stdio: 'ignore' });
|
|
791
828
|
return 'tmux';
|
|
792
829
|
} catch {}
|
|
793
830
|
}
|
|
@@ -867,7 +904,6 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
|
|
|
867
904
|
// path.sep keeps the prefixes unambiguous: without it ~/.kimi would also
|
|
868
905
|
// match every ~/.kimi-code path and routing would depend on check order.
|
|
869
906
|
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
907
|
if (file.startsWith(KIMICODE_DIR + path.sep)) return readKimiCodeMessages(file, maxBytes, limit);
|
|
872
908
|
if (file.startsWith(KIMI_DIR + path.sep)) return readKimiLegacyMessages(file, maxBytes, limit);
|
|
873
909
|
let raw;
|
|
@@ -996,6 +1032,15 @@ function codexMetaOf(file) {
|
|
|
996
1032
|
id: j.payload.session_id,
|
|
997
1033
|
cwd: j.payload.cwd || '',
|
|
998
1034
|
startedAt: Date.parse(j.payload.timestamp || j.timestamp) || 0,
|
|
1035
|
+
// Codex spawns its own helpers (the review subagent, for one) as
|
|
1036
|
+
// separate rollout files that carry the *parent's* session_id. Two
|
|
1037
|
+
// files, one id: the phone would draw one pilot and leave a hole
|
|
1038
|
+
// where the other should be, because both the crew list and the
|
|
1039
|
+
// island key on the session id. A helper is not a pilot anyway, so
|
|
1040
|
+
// it is marked here and dropped by the scan, which keeps the answer
|
|
1041
|
+
// in the cache rather than re-reading a growing file every sweep.
|
|
1042
|
+
helper: j.payload.thread_source === 'subagent'
|
|
1043
|
+
|| (!!j.payload.parent_thread_id && j.payload.parent_thread_id !== j.payload.id),
|
|
999
1044
|
};
|
|
1000
1045
|
}
|
|
1001
1046
|
} catch {}
|
|
@@ -1090,17 +1135,29 @@ function readCodexSessions() {
|
|
|
1090
1135
|
codexCache.at = Date.now();
|
|
1091
1136
|
const names = codexThreadNames();
|
|
1092
1137
|
const sessions = [];
|
|
1138
|
+
// One pilot per folder, not one per run.
|
|
1139
|
+
//
|
|
1140
|
+
// Codex has no long lived session the way Claude Code does: every `codex
|
|
1141
|
+
// exec` writes its own rollout, so asking it to review a change three times
|
|
1142
|
+
// in an afternoon used to put three identical pilots on the island, all in
|
|
1143
|
+
// the same directory, none of them the one you meant. The freshest rollout
|
|
1144
|
+
// in a folder is the one worth talking to, and prompting resumes it by id,
|
|
1145
|
+
// so the older ones have nothing left to offer.
|
|
1146
|
+
const newestPerCwd = new Map(); // cwd -> index into sessions
|
|
1093
1147
|
for (const file of codexRolloutFiles()) {
|
|
1094
1148
|
let mtime;
|
|
1095
1149
|
try { mtime = fs.statSync(file).mtimeMs; } catch { continue; }
|
|
1096
1150
|
if (Date.now() - mtime > CODEX_ACTIVE_WINDOW_MS) continue;
|
|
1097
1151
|
const meta = codexMetaOf(file);
|
|
1098
|
-
if (!meta) continue;
|
|
1152
|
+
if (!meta || meta.helper) continue;
|
|
1099
1153
|
const act = memoScan(`codex:${file}`, file, () => codexActivityOf(file));
|
|
1100
1154
|
const busy = (act.busy || (!act.sawMarkers && Date.now() - mtime < 60_000))
|
|
1101
1155
|
&& Date.now() - mtime < CODEX_BUSY_STALE_MS;
|
|
1156
|
+
const key = meta.cwd || `id:${meta.id}`;
|
|
1157
|
+
const seen = newestPerCwd.get(key);
|
|
1158
|
+
if (seen !== undefined && sessions[seen].updatedAt >= mtime) continue;
|
|
1102
1159
|
transcriptCache.set(meta.id, file);
|
|
1103
|
-
|
|
1160
|
+
const session = {
|
|
1104
1161
|
id: meta.id,
|
|
1105
1162
|
provider: 'codex',
|
|
1106
1163
|
name: names.get(meta.id) || path.basename(meta.cwd || '?'),
|
|
@@ -1112,7 +1169,9 @@ function readCodexSessions() {
|
|
|
1112
1169
|
lastMessage: act.lastMessage,
|
|
1113
1170
|
tty: '',
|
|
1114
1171
|
live: false,
|
|
1115
|
-
}
|
|
1172
|
+
};
|
|
1173
|
+
if (seen !== undefined) sessions[seen] = session;
|
|
1174
|
+
else { newestPerCwd.set(key, sessions.length); sessions.push(session); }
|
|
1116
1175
|
}
|
|
1117
1176
|
codexCache.sessions = sessions;
|
|
1118
1177
|
return sessions;
|
|
@@ -1439,157 +1498,6 @@ function readKimiSessions() {
|
|
|
1439
1498
|
return kimiCache.sessions;
|
|
1440
1499
|
}
|
|
1441
1500
|
|
|
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
1501
|
/// The phone-facing shape of a held request.
|
|
1594
1502
|
const askPayload = (ask, queued) => ({
|
|
1595
1503
|
id: ask.id,
|
|
@@ -1601,7 +1509,7 @@ const askPayload = (ask, queued) => ({
|
|
|
1601
1509
|
});
|
|
1602
1510
|
|
|
1603
1511
|
function readSessions() {
|
|
1604
|
-
const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()
|
|
1512
|
+
const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
|
|
1605
1513
|
const known = new Set(sessions.map((s) => s.id));
|
|
1606
1514
|
for (const s of sessions) {
|
|
1607
1515
|
const queue = askQueue.get(s.id);
|
|
@@ -1640,7 +1548,20 @@ function readSessions() {
|
|
|
1640
1548
|
}
|
|
1641
1549
|
|
|
1642
1550
|
sessions.sort((a, b) => a.startedAt - b.startedAt);
|
|
1643
|
-
|
|
1551
|
+
// One row per id, whatever the providers hand over.
|
|
1552
|
+
//
|
|
1553
|
+
// The phone keys every pilot on the session id: the crew list draws a
|
|
1554
|
+
// ForEach over it and the island keeps one sprite per id. Two rows sharing
|
|
1555
|
+
// one id do not draw twice, they draw once and leave a hole the height of a
|
|
1556
|
+
// card where the other should be, which reads as a layout bug rather than
|
|
1557
|
+
// as bad data. Codex was the source of that once already; this is here so
|
|
1558
|
+
// the next provider that repeats an id cannot do it again.
|
|
1559
|
+
const byId = new Map();
|
|
1560
|
+
for (const s of sessions) {
|
|
1561
|
+
const seen = byId.get(s.id);
|
|
1562
|
+
if (!seen || (s.updatedAt || 0) > (seen.updatedAt || 0)) byId.set(s.id, s);
|
|
1563
|
+
}
|
|
1564
|
+
return byId.size === sessions.length ? sessions : sessions.filter((s) => byId.get(s.id) === s);
|
|
1644
1565
|
}
|
|
1645
1566
|
|
|
1646
1567
|
// ---------- remote approvals: runtime ----------
|
|
@@ -1685,6 +1606,8 @@ function hostState() {
|
|
|
1685
1606
|
const bustHostCache = () => { hostCache.at = 0; };
|
|
1686
1607
|
|
|
1687
1608
|
let approvalServerUp = false;
|
|
1609
|
+
let approvalServer = null;
|
|
1610
|
+
let approvalSocketIno = 0; // which file at ASK_SOCKET is ours
|
|
1688
1611
|
let availableUpdate = null; // version string once a newer release is seen
|
|
1689
1612
|
|
|
1690
1613
|
const pendingAsks = new Map(); // askId -> ask record
|
|
@@ -1821,6 +1744,9 @@ function onPermissionRequest(req, socket) {
|
|
|
1821
1744
|
at: Date.now(),
|
|
1822
1745
|
socket,
|
|
1823
1746
|
done: false,
|
|
1747
|
+
// Set once Claude Code has actually reported this session as waiting, so
|
|
1748
|
+
// releaseAnsweredOnMac knows there was a prompt on the Mac to answer.
|
|
1749
|
+
sawWaiting: false,
|
|
1824
1750
|
};
|
|
1825
1751
|
ask.holdTimer = setTimeout(
|
|
1826
1752
|
() => settleAsk(ask, null, 'released', 'held too long, prompting on the Mac instead'),
|
|
@@ -1881,12 +1807,33 @@ function startApprovalServer(attempt = 0) {
|
|
|
1881
1807
|
});
|
|
1882
1808
|
server.listen(ASK_SOCKET, () => {
|
|
1883
1809
|
try { fs.chmodSync(ASK_SOCKET, 0o600); } catch {}
|
|
1810
|
+
try { approvalSocketIno = fs.statSync(ASK_SOCKET).ino; } catch {}
|
|
1811
|
+
approvalServer = server;
|
|
1884
1812
|
approvalServerUp = true;
|
|
1885
1813
|
bustHostCache();
|
|
1886
1814
|
console.log(`[daemon] remote approvals ready (mode: ${approvalConfig().mode})`);
|
|
1887
1815
|
});
|
|
1888
1816
|
}
|
|
1889
1817
|
|
|
1818
|
+
/// A second copy started on this Mac unlinks the approvals socket and binds
|
|
1819
|
+
/// its own. When that copy then loses the relay back to this one and exits,
|
|
1820
|
+
/// the hook is left calling a file nobody listens on, and approvals stop
|
|
1821
|
+
/// reaching the phone until the next restart. Up to 0.1.11 the restart came
|
|
1822
|
+
/// quickly, because a displaced daemon exited; now that it waits instead,
|
|
1823
|
+
/// it has to take the socket back itself, every time the relay is ours again.
|
|
1824
|
+
function ensureApprovalSocket() {
|
|
1825
|
+
if (!approvalServer) return;
|
|
1826
|
+
let ino = 0;
|
|
1827
|
+
try { ino = fs.statSync(ASK_SOCKET).ino; } catch {}
|
|
1828
|
+
if (ino && ino === approvalSocketIno) return;
|
|
1829
|
+
console.log('[daemon] another copy replaced the approvals socket, taking it back');
|
|
1830
|
+
try { approvalServer.close(); } catch {}
|
|
1831
|
+
approvalServer = null;
|
|
1832
|
+
approvalServerUp = false;
|
|
1833
|
+
bustHostCache();
|
|
1834
|
+
startApprovalServer();
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1890
1837
|
// Claude Code's own status for each session, as last seen on disk.
|
|
1891
1838
|
const lastRawStatus = new Map(); // sessionId -> 'busy' | 'idle' | 'waiting' | ...
|
|
1892
1839
|
|
|
@@ -1905,10 +1852,30 @@ const ANSWERED_GRACE_MS = 6000;
|
|
|
1905
1852
|
function releaseAnsweredOnMac() {
|
|
1906
1853
|
if (!pendingAsks.size) return;
|
|
1907
1854
|
for (const ask of [...pendingAsks.values()]) {
|
|
1908
|
-
if (Date.now() - ask.at < ANSWERED_GRACE_MS) continue;
|
|
1909
1855
|
const status = lastRawStatus.get(ask.sessionId);
|
|
1910
1856
|
// Unknown session: nothing to conclude, leave it to the socket or the cap.
|
|
1911
|
-
if (!status
|
|
1857
|
+
if (!status) continue;
|
|
1858
|
+
// Recorded before the grace period is checked, not after: the prompt is
|
|
1859
|
+
// most likely to be showing as waiting in exactly those first seconds.
|
|
1860
|
+
if (status === 'waiting') { ask.sawWaiting = true; continue; }
|
|
1861
|
+
if (Date.now() - ask.at < ANSWERED_GRACE_MS) continue;
|
|
1862
|
+
// Not waiting is only evidence of an answer if we watched the prompt go
|
|
1863
|
+
// up in the first place.
|
|
1864
|
+
//
|
|
1865
|
+
// Claude Code writes "waiting" for the prompt it draws itself, and a
|
|
1866
|
+
// permission request raised from inside a running turn never gets one:
|
|
1867
|
+
// the session sits on "busy" for as long as the hook blocks. Concluding
|
|
1868
|
+
// from that alone declared every gated command inside a turn answered six
|
|
1869
|
+
// seconds after it was asked, so the card vanished off the phone mid tap
|
|
1870
|
+
// and the session stayed stopped on a question nobody could answer from
|
|
1871
|
+
// there any more.
|
|
1872
|
+
//
|
|
1873
|
+
// Idle is the signal that is safe to read instead. A session cannot go
|
|
1874
|
+
// idle while a hook is blocking a tool call, so idle means the turn ran
|
|
1875
|
+
// to the end and this request is long settled, whatever settled it. Busy
|
|
1876
|
+
// and shell mean the turn is still in flight, which is exactly when the
|
|
1877
|
+
// card has to stay put.
|
|
1878
|
+
if (!ask.sawWaiting && (status === 'busy' || status === 'shell')) continue;
|
|
1912
1879
|
settleAsk(ask, null, 'gone', 'answered on the Mac');
|
|
1913
1880
|
}
|
|
1914
1881
|
}
|
|
@@ -1921,6 +1888,9 @@ function releaseAnsweredOnMac() {
|
|
|
1921
1888
|
function releaseAsksIfBack() {
|
|
1922
1889
|
if (!pendingAsks.size) return;
|
|
1923
1890
|
const cfg = approvalConfig();
|
|
1891
|
+
// `groundcontrol approvals off` runs in another process; this is where the
|
|
1892
|
+
// daemon notices it and hands back whatever it was still holding.
|
|
1893
|
+
if (cfg.mode === 'off') return releaseAllAsks('remote approvals were turned off');
|
|
1924
1894
|
if (cfg.mode !== 'away') return;
|
|
1925
1895
|
if (macIdleSeconds() >= cfg.awayAfterSeconds) return;
|
|
1926
1896
|
for (const ask of [...pendingAsks.values()]) {
|
|
@@ -1950,8 +1920,11 @@ function isNewer(candidate, current) {
|
|
|
1950
1920
|
}
|
|
1951
1921
|
|
|
1952
1922
|
async function latestPublishedVersion() {
|
|
1923
|
+
// Plain JSON. The abbreviated install-v1 format only exists for the whole
|
|
1924
|
+
// package document; asked of /latest the registry answers 406, and it did,
|
|
1925
|
+
// on every check from 0.1.7 through 0.1.11, so no install ever updated.
|
|
1953
1926
|
const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
|
|
1954
|
-
headers: { accept: 'application/
|
|
1927
|
+
headers: { accept: 'application/json' },
|
|
1955
1928
|
signal: AbortSignal.timeout(15_000),
|
|
1956
1929
|
});
|
|
1957
1930
|
if (!res.ok) throw new Error(`registry ${res.status}`);
|
|
@@ -1999,9 +1972,9 @@ async function checkForUpdate({ force = false } = {}) {
|
|
|
1999
1972
|
|
|
2000
1973
|
/// A daemon shutting down must hand every held request back rather than leave
|
|
2001
1974
|
/// sessions blocked on a socket that will never answer.
|
|
2002
|
-
function releaseAllAsks() {
|
|
1975
|
+
function releaseAllAsks(note = 'the daemon stopped') {
|
|
2003
1976
|
for (const ask of [...pendingAsks.values()]) {
|
|
2004
|
-
settleAsk(ask, null, 'released',
|
|
1977
|
+
settleAsk(ask, null, 'released', note);
|
|
2005
1978
|
}
|
|
2006
1979
|
}
|
|
2007
1980
|
|
|
@@ -2010,6 +1983,17 @@ function releaseAllAsks() {
|
|
|
2010
1983
|
let ws = null;
|
|
2011
1984
|
let lastSnapshotJson = '';
|
|
2012
1985
|
|
|
1986
|
+
/// Everything bound for the phone goes through here. The socket is replaced
|
|
1987
|
+
/// on every reconnect, and ws throws, rather than returning, when asked to
|
|
1988
|
+
/// send on one that is still connecting. From a file watcher or a child
|
|
1989
|
+
/// process callback that is an uncaught exception, which takes the daemon and
|
|
1990
|
+
/// every held approval down with it. The relay keeps no queue, so a message
|
|
1991
|
+
/// with no open socket is lost either way; the phone has timeouts for that.
|
|
1992
|
+
function toPhone(obj) {
|
|
1993
|
+
if (ws?.readyState !== 1) return false;
|
|
1994
|
+
try { ws.send(seal(obj)); return true; } catch { return false; }
|
|
1995
|
+
}
|
|
1996
|
+
|
|
2013
1997
|
function sendSnapshot(force = false) {
|
|
2014
1998
|
// A parser tripping on a malformed journal must never take the daemon down:
|
|
2015
1999
|
// this runs on a bare interval with nothing above it to catch a throw.
|
|
@@ -2033,43 +2017,102 @@ function sendSnapshot(force = false) {
|
|
|
2033
2017
|
|
|
2034
2018
|
// Live transcript streaming: while a phone has a chat open, push transcript
|
|
2035
2019
|
// updates the moment the JSONL file changes.
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2020
|
+
//
|
|
2021
|
+
// Every open chat holds a lease on its watch. Apps from 0.1.9 name themselves
|
|
2022
|
+
// and renew every minute, so one phone closing a chat no longer stops it
|
|
2023
|
+
// streaming on another, and a phone that vanishes without an unwatch (killed
|
|
2024
|
+
// app, dead network) stops costing this Mac a file watch plus a full
|
|
2025
|
+
// transcript read on every write within minutes instead of never. Older apps
|
|
2026
|
+
// never renew, so theirs is long and renewed whenever they ask for the log.
|
|
2027
|
+
const WATCH_LEASE_MS = 3 * 60_000;
|
|
2028
|
+
const LEGACY_LEASE_MS = 30 * 60_000;
|
|
2029
|
+
const PROMPT_LEASE_MS = 5 * 60_000;
|
|
2030
|
+
const leaseFor = (holder) => (holder === 'legacy' ? LEGACY_LEASE_MS : holder === 'prompt' ? PROMPT_LEASE_MS : WATCH_LEASE_MS);
|
|
2031
|
+
const holderOf = (cmd) => (typeof cmd.client === 'string' && cmd.client ? `phone:${cmd.client.slice(0, 64)}` : 'legacy');
|
|
2032
|
+
const watchers = new Map(); // sessionId -> { watcher, debounce, leases: Map<holder, expiresAt> }
|
|
2033
|
+
// Sessions whose legacy lease ran out rather than being unwatched: the chat
|
|
2034
|
+
// may well still be open on an older phone, which asks for the log again
|
|
2035
|
+
// whenever the session changes state. That is the moment to resume.
|
|
2036
|
+
const lapsedLegacy = new Map(); // sessionId -> when the lease ran out
|
|
2037
|
+
const LAPSED_FORGET_MS = 24 * 60 * 60 * 1000;
|
|
2038
|
+
|
|
2039
|
+
/// Start or renew a watch. Returns 'created', 'renewed', or null when there
|
|
2040
|
+
/// is no transcript to watch.
|
|
2041
|
+
function watchTranscript(sessionId, holder = 'legacy', now = Date.now()) {
|
|
2042
|
+
if (holder === 'legacy') lapsedLegacy.delete(sessionId);
|
|
2043
|
+
const until = now + leaseFor(holder);
|
|
2044
|
+
const entry = watchers.get(sessionId);
|
|
2045
|
+
if (entry) {
|
|
2046
|
+
entry.leases.set(holder, Math.max(until, entry.leases.get(holder) || 0));
|
|
2047
|
+
return 'renewed';
|
|
2048
|
+
}
|
|
2039
2049
|
const file = findTranscript(sessionId);
|
|
2040
|
-
if (!file) return;
|
|
2041
|
-
|
|
2050
|
+
if (!file) return null;
|
|
2051
|
+
const next = { watcher: null, debounce: null, leases: new Map([[holder, until]]) };
|
|
2042
2052
|
try {
|
|
2043
|
-
// Journals are appended to, so watching the file is enough.
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
const target = inGrokBot ? GROKBOT_DIR : file;
|
|
2049
|
-
const w = fs.watch(target, (_ev, changed) => {
|
|
2050
|
-
if (inGrokBot && changed && changed !== path.basename(file)) return;
|
|
2051
|
-
clearTimeout(debounce);
|
|
2052
|
-
debounce = setTimeout(() => {
|
|
2053
|
-
ws?.send(seal({ type: 'transcript', sessionId, messages: readMessages(sessionId) }));
|
|
2053
|
+
// Journals are appended to, so watching the file is enough.
|
|
2054
|
+
next.watcher = fs.watch(file, () => {
|
|
2055
|
+
clearTimeout(next.debounce);
|
|
2056
|
+
next.debounce = setTimeout(() => {
|
|
2057
|
+
toPhone({ type: 'transcript', sessionId, messages: readMessages(sessionId) });
|
|
2054
2058
|
}, 250);
|
|
2055
2059
|
});
|
|
2056
|
-
watchers.set(sessionId,
|
|
2060
|
+
watchers.set(sessionId, next);
|
|
2057
2061
|
console.log(`[daemon] watching transcript ${sessionId.slice(0, 8)}`);
|
|
2058
|
-
|
|
2062
|
+
return 'created';
|
|
2063
|
+
} catch { return null; }
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/// Keep an existing lease alive without starting a watch nobody asked for,
|
|
2067
|
+
/// except the one an older app's open chat lost to the clock.
|
|
2068
|
+
function renewWatch(sessionId, holder, now = Date.now()) {
|
|
2069
|
+
const entry = watchers.get(sessionId);
|
|
2070
|
+
if (entry?.leases.has(holder)) {
|
|
2071
|
+
entry.leases.set(holder, now + leaseFor(holder));
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
if (holder === 'legacy' && lapsedLegacy.has(sessionId)) watchTranscript(sessionId, 'legacy', now);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function unwatchTranscript(sessionId, holder = 'legacy') {
|
|
2078
|
+
if (holder === 'legacy') lapsedLegacy.delete(sessionId);
|
|
2079
|
+
const entry = watchers.get(sessionId);
|
|
2080
|
+
if (!entry) return;
|
|
2081
|
+
entry.leases.delete(holder);
|
|
2082
|
+
if (!entry.leases.size) closeWatch(sessionId, entry);
|
|
2059
2083
|
}
|
|
2060
|
-
|
|
2061
|
-
|
|
2084
|
+
|
|
2085
|
+
function closeWatch(sessionId, entry) {
|
|
2086
|
+
clearTimeout(entry.debounce);
|
|
2087
|
+
try { entry.watcher?.close(); } catch {}
|
|
2062
2088
|
watchers.delete(sessionId);
|
|
2063
2089
|
}
|
|
2064
2090
|
|
|
2091
|
+
function expireWatches(now = Date.now()) {
|
|
2092
|
+
for (const [sessionId, entry] of watchers) {
|
|
2093
|
+
for (const [holder, until] of entry.leases) {
|
|
2094
|
+
if (until > now) continue;
|
|
2095
|
+
entry.leases.delete(holder);
|
|
2096
|
+
if (holder === 'legacy') lapsedLegacy.set(sessionId, now);
|
|
2097
|
+
}
|
|
2098
|
+
if (!entry.leases.size) closeWatch(sessionId, entry);
|
|
2099
|
+
}
|
|
2100
|
+
for (const [sessionId, at] of lapsedLegacy) if (now - at > LAPSED_FORGET_MS) lapsedLegacy.delete(sessionId);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2065
2103
|
async function handleCommand(cmd) {
|
|
2066
2104
|
if (cmd.type === 'getTranscript' && cmd.sessionId) {
|
|
2067
|
-
|
|
2105
|
+
renewWatch(cmd.sessionId, holderOf(cmd));
|
|
2106
|
+
toPhone({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) });
|
|
2068
2107
|
} else if (cmd.type === 'watch' && cmd.sessionId) {
|
|
2069
|
-
watchTranscript(cmd.sessionId);
|
|
2070
|
-
|
|
2108
|
+
const how = watchTranscript(cmd.sessionId, holderOf(cmd));
|
|
2109
|
+
// A renewal only keeps the lease; the phone already has the log, unless
|
|
2110
|
+
// this daemon restarted since and the watch had to be started again.
|
|
2111
|
+
if (!cmd.renew || how === 'created') {
|
|
2112
|
+
toPhone({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) });
|
|
2113
|
+
}
|
|
2071
2114
|
} else if (cmd.type === 'unwatch' && cmd.sessionId) {
|
|
2072
|
-
unwatchTranscript(cmd.sessionId);
|
|
2115
|
+
unwatchTranscript(cmd.sessionId, holderOf(cmd));
|
|
2073
2116
|
} else if (cmd.type === 'setApprovals') {
|
|
2074
2117
|
// A deliberate tap in the app, with the app explaining what it adds, is
|
|
2075
2118
|
// the consent for this. A backup is still written before any change.
|
|
@@ -2077,6 +2120,8 @@ async function handleCommand(cmd) {
|
|
|
2077
2120
|
if (cmd.enabled === false) {
|
|
2078
2121
|
writeApprovalConfig({ mode: 'off' });
|
|
2079
2122
|
result = uninstallApprovalHook();
|
|
2123
|
+
// Off means off for what is already held too, not just what comes next.
|
|
2124
|
+
releaseAllAsks('remote approvals were turned off');
|
|
2080
2125
|
console.log('[daemon] remote approvals turned off from the phone');
|
|
2081
2126
|
} else {
|
|
2082
2127
|
const mode = ['away', 'always'].includes(cmd.mode) ? cmd.mode : 'away';
|
|
@@ -2086,21 +2131,21 @@ async function handleCommand(cmd) {
|
|
|
2086
2131
|
}
|
|
2087
2132
|
bustHostCache();
|
|
2088
2133
|
sendSnapshot(true);
|
|
2089
|
-
|
|
2134
|
+
toPhone({
|
|
2090
2135
|
type: 'approvalsResult',
|
|
2091
2136
|
ok: !!result.ok,
|
|
2092
2137
|
error: result.ok ? '' : (result.reason || 'could not change the setting'),
|
|
2093
2138
|
host: hostState(),
|
|
2094
|
-
})
|
|
2139
|
+
});
|
|
2095
2140
|
} else if (cmd.type === 'approve' || cmd.type === 'deny') {
|
|
2096
2141
|
const ask = cmd.askId ? pendingAsks.get(cmd.askId) : null;
|
|
2097
2142
|
if (!ask) {
|
|
2098
2143
|
// Answered on the Mac, expired, or the session moved on while the tap
|
|
2099
2144
|
// was in flight. Tell the phone so the card clears instead of hanging.
|
|
2100
|
-
return
|
|
2145
|
+
return toPhone({
|
|
2101
2146
|
type: 'askResolved', askId: cmd.askId || '', sessionId: cmd.sessionId || '',
|
|
2102
2147
|
outcome: 'gone', note: 'that request is no longer waiting',
|
|
2103
|
-
})
|
|
2148
|
+
});
|
|
2104
2149
|
}
|
|
2105
2150
|
if (cmd.type === 'approve') {
|
|
2106
2151
|
settleAsk(ask, { behavior: 'allow', ...(cmd.always ? { alwaysAllow: true } : {}) },
|
|
@@ -2111,28 +2156,19 @@ async function handleCommand(cmd) {
|
|
|
2111
2156
|
'denied', reason ? `from the phone: ${reason.slice(0, 60)}` : 'from the phone');
|
|
2112
2157
|
}
|
|
2113
2158
|
} else if (cmd.type === 'projects') {
|
|
2114
|
-
|
|
2159
|
+
toPhone({
|
|
2115
2160
|
type: 'projects',
|
|
2116
2161
|
projects: knownProjects().map((p) => ({ path: p.path, name: p.name, at: p.at })),
|
|
2117
2162
|
providers: launchProviders(),
|
|
2118
|
-
})
|
|
2163
|
+
});
|
|
2119
2164
|
} else if (cmd.type === 'launch') {
|
|
2120
2165
|
await launchSession(cmd);
|
|
2121
2166
|
} else if (cmd.type === 'ping' && cmd.sessionId) {
|
|
2122
2167
|
const s = readSessions().find((x) => x.id === cmd.sessionId);
|
|
2123
|
-
|
|
2168
|
+
toPhone({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() });
|
|
2124
2169
|
} else if (cmd.type === 'prompt' && cmd.sessionId && cmd.text) {
|
|
2125
2170
|
const session = readSessions().find((s) => s.id === cmd.sessionId);
|
|
2126
|
-
if (!session) return
|
|
2127
|
-
|
|
2128
|
-
// A Bot lives on a cloud VM this daemon has no channel to. Saying so beats
|
|
2129
|
-
// a send that looks accepted and arrives nowhere.
|
|
2130
|
-
if (session.provider === 'grokbot') {
|
|
2131
|
-
return ws?.send(seal({
|
|
2132
|
-
type: 'promptResult', sessionId: cmd.sessionId, ok: false,
|
|
2133
|
-
error: 'Grok Bot runs in the cloud, so Ground Control can only watch it. Open the Grok Bot app to reply.',
|
|
2134
|
-
}));
|
|
2135
|
-
}
|
|
2171
|
+
if (!session) return toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' });
|
|
2136
2172
|
|
|
2137
2173
|
if (session.provider && session.provider !== 'claude') return promptHeadless(session, cmd.text);
|
|
2138
2174
|
|
|
@@ -2141,27 +2177,27 @@ async function handleCommand(cmd) {
|
|
|
2141
2177
|
const method = await injectPrompt(session.tty, cmd.text);
|
|
2142
2178
|
if (method) {
|
|
2143
2179
|
console.log(`[daemon] injected prompt into ${session.name} via ${method}`);
|
|
2144
|
-
|
|
2145
|
-
watchTranscript(cmd.sessionId);
|
|
2180
|
+
toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: true, text: '' });
|
|
2181
|
+
watchTranscript(cmd.sessionId, 'prompt');
|
|
2146
2182
|
sendSnapshot(true);
|
|
2147
2183
|
return;
|
|
2148
2184
|
}
|
|
2149
2185
|
if (automationDenied) {
|
|
2150
2186
|
console.log('[daemon] prompt blocked: macOS Automation permission missing');
|
|
2151
|
-
return
|
|
2187
|
+
return toPhone({
|
|
2152
2188
|
type: 'promptResult', sessionId: cmd.sessionId, ok: false,
|
|
2153
2189
|
error: 'Your Mac is blocking terminal control. Open System Settings, Privacy and Security, Automation, and allow node to control your terminal app. Then send again.',
|
|
2154
|
-
})
|
|
2190
|
+
});
|
|
2155
2191
|
}
|
|
2156
2192
|
}
|
|
2157
|
-
if (session.status === 'busy') return
|
|
2193
|
+
if (session.status === 'busy') return toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session is busy, wait for it to go idle' });
|
|
2158
2194
|
const bin = claudeBin();
|
|
2159
2195
|
if (!bin) {
|
|
2160
2196
|
console.log('[daemon] prompt failed: claude binary not found on PATH');
|
|
2161
|
-
return
|
|
2197
|
+
return toPhone({
|
|
2162
2198
|
type: 'promptResult', sessionId: cmd.sessionId, ok: false,
|
|
2163
2199
|
error: 'The daemon could not find the claude command on this Mac. Open a terminal, check that claude runs, then reinstall with: npx @clastres/groundcontrol',
|
|
2164
|
-
})
|
|
2200
|
+
});
|
|
2165
2201
|
}
|
|
2166
2202
|
console.log(`[daemon] prompt -> ${session.name}: ${cmd.text.slice(0, 60)}`);
|
|
2167
2203
|
headlessActive.add(cmd.sessionId);
|
|
@@ -2177,11 +2213,13 @@ async function handleCommand(cmd) {
|
|
|
2177
2213
|
if (responded) return;
|
|
2178
2214
|
responded = true;
|
|
2179
2215
|
headlessActive.delete(cmd.sessionId);
|
|
2180
|
-
|
|
2216
|
+
toPhone(payload);
|
|
2181
2217
|
sendSnapshot(true);
|
|
2182
2218
|
};
|
|
2183
2219
|
child.stdout.on('data', (d) => { out += d; });
|
|
2184
2220
|
child.stderr.on('data', (d) => { err += d; });
|
|
2221
|
+
// Delivered is not done: the turn can run for minutes. See promptAccepted.
|
|
2222
|
+
child.on('spawn', () => promptAccepted(cmd.sessionId));
|
|
2185
2223
|
child.on('error', (e) => {
|
|
2186
2224
|
console.log(`[daemon] claude spawn failed: ${e.message}`);
|
|
2187
2225
|
respond({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: `could not run claude: ${e.message}` });
|
|
@@ -2190,6 +2228,7 @@ async function handleCommand(cmd) {
|
|
|
2190
2228
|
respond({
|
|
2191
2229
|
type: 'promptResult', sessionId: cmd.sessionId, ok: code === 0,
|
|
2192
2230
|
text: (out || err).trim().slice(0, 4000),
|
|
2231
|
+
...(code === 0 ? {} : { error: failureText('claude', code, err || out) }),
|
|
2193
2232
|
});
|
|
2194
2233
|
});
|
|
2195
2234
|
}
|
|
@@ -2213,21 +2252,20 @@ const headlessPromptSpec = {
|
|
|
2213
2252
|
function bustProviderCaches() {
|
|
2214
2253
|
codexCache.at = 0;
|
|
2215
2254
|
kimiCache.at = 0;
|
|
2216
|
-
grokbotCache.at = 0;
|
|
2217
2255
|
}
|
|
2218
2256
|
|
|
2219
2257
|
function promptHeadless(session, text) {
|
|
2220
2258
|
// Anything but idle means another writer owns the session right now: busy
|
|
2221
2259
|
// is a running turn, waiting is a human approval the phone cannot answer.
|
|
2222
|
-
if (session.status === 'busy') return
|
|
2223
|
-
if (session.status !== 'idle') return
|
|
2260
|
+
if (session.status === 'busy') return toPhone({ type: 'promptResult', sessionId: session.id, ok: false, error: 'session is busy, wait for it to go idle' });
|
|
2261
|
+
if (session.status !== 'idle') return toPhone({ type: 'promptResult', sessionId: session.id, ok: false, error: 'session is waiting for an approval, answer it in the app or terminal first' });
|
|
2224
2262
|
const spec = headlessPromptSpec[session.provider]?.();
|
|
2225
2263
|
if (!spec) {
|
|
2226
2264
|
console.log(`[daemon] ${session.provider} prompt failed: binary not found`);
|
|
2227
|
-
return
|
|
2265
|
+
return toPhone({
|
|
2228
2266
|
type: 'promptResult', sessionId: session.id, ok: false,
|
|
2229
2267
|
error: `The daemon could not find the ${session.provider === 'codex' ? 'codex' : 'kimi'} command on this Mac, so it cannot reach this session. If you just installed it, try again in a minute.`,
|
|
2230
|
-
})
|
|
2268
|
+
});
|
|
2231
2269
|
}
|
|
2232
2270
|
console.log(`[daemon] prompt -> ${session.provider} ${session.name}: ${text.slice(0, 60)}`);
|
|
2233
2271
|
const promptedAt = Date.now();
|
|
@@ -2246,11 +2284,12 @@ function promptHeadless(session, text) {
|
|
|
2246
2284
|
responded = true;
|
|
2247
2285
|
headlessActive.delete(session.id);
|
|
2248
2286
|
bustProviderCaches();
|
|
2249
|
-
|
|
2287
|
+
toPhone(payload);
|
|
2250
2288
|
sendSnapshot(true);
|
|
2251
2289
|
};
|
|
2252
2290
|
child.stderr.on('data', (d) => { err += d; });
|
|
2253
2291
|
child.stdout.on('data', () => {});
|
|
2292
|
+
child.on('spawn', () => promptAccepted(session.id));
|
|
2254
2293
|
child.on('error', (e) => {
|
|
2255
2294
|
console.log(`[daemon] ${session.provider} spawn failed: ${e.message}`);
|
|
2256
2295
|
respond({ type: 'promptResult', sessionId: session.id, ok: false, error: `could not run ${session.provider}: ${e.message}` });
|
|
@@ -2265,9 +2304,26 @@ function promptHeadless(session, text) {
|
|
|
2265
2304
|
respond({
|
|
2266
2305
|
type: 'promptResult', sessionId: session.id, ok: code === 0,
|
|
2267
2306
|
text: code === 0 ? (reply?.text || '').slice(0, 4000) : err.trim().slice(0, 4000),
|
|
2307
|
+
...(code === 0 ? {} : { error: failureText(session.provider, code, err) }),
|
|
2268
2308
|
});
|
|
2269
2309
|
});
|
|
2270
|
-
watchTranscript(session.id);
|
|
2310
|
+
watchTranscript(session.id, 'prompt');
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
/// Tell the phone a headless run has started. The run answers only when the
|
|
2314
|
+
/// whole turn is over, often minutes later, and the app gives up on a prompt
|
|
2315
|
+
/// after 30 seconds: up to 0.1.8 it then showed "no reply from mac yet" and
|
|
2316
|
+
/// kept showing it after the run succeeded. An ok now clears the pending
|
|
2317
|
+
/// prompt; a failure later still arrives as its own result.
|
|
2318
|
+
function promptAccepted(sessionId) {
|
|
2319
|
+
toPhone({ type: 'promptResult', sessionId, ok: true, text: '', accepted: true });
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/// A failed run's last words, for the phone. Whatever the agent printed on
|
|
2323
|
+
/// stderr last is usually the reason; the exit code is the fallback.
|
|
2324
|
+
function failureText(provider, code, output) {
|
|
2325
|
+
const tail = String(output || '').trim().split('\n').filter(Boolean).slice(-3).join(' ').slice(0, 300);
|
|
2326
|
+
return tail ? `${provider} stopped: ${tail}` : `${provider} exited with code ${code}`;
|
|
2271
2327
|
}
|
|
2272
2328
|
|
|
2273
2329
|
// ---------- launching a new pilot ----------
|
|
@@ -2368,7 +2424,7 @@ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
|
2368
2424
|
let lastLaunchAt = 0;
|
|
2369
2425
|
|
|
2370
2426
|
async function launchSession(cmd) {
|
|
2371
|
-
const fail = (error) =>
|
|
2427
|
+
const fail = (error) => toPhone({ type: 'launchResult', ok: false, error });
|
|
2372
2428
|
|
|
2373
2429
|
const providers = launchProviders();
|
|
2374
2430
|
const provider = cmd.provider || 'claude';
|
|
@@ -2426,18 +2482,60 @@ end tell`);
|
|
|
2426
2482
|
// The new session reaches the island through the same discovery every other
|
|
2427
2483
|
// one does, which takes a few seconds. Say so rather than implying the pilot
|
|
2428
2484
|
// is already there.
|
|
2429
|
-
|
|
2485
|
+
toPhone({ type: 'launchResult', ok: true, cwd: target.path, note: `${provider} is starting in ${target.name}` });
|
|
2430
2486
|
sendSnapshot(true);
|
|
2431
2487
|
}
|
|
2432
2488
|
|
|
2489
|
+
// Replay protection. The relay only ever sees ciphertext, but it could send
|
|
2490
|
+
// a captured command again, and it would decrypt just fine. Every sealed
|
|
2491
|
+
// message opens with a random 12 byte nonce, so one seen twice is a replay.
|
|
2492
|
+
// Keyed on the decoded bytes, not the text: base64 decoding skips whitespace
|
|
2493
|
+
// and takes either alphabet, so one frame can be spelled many ways.
|
|
2494
|
+
// Apps from 0.1.9 also stamp each command with when it was sent, which turns
|
|
2495
|
+
// away old captures even after a restart has emptied this cache. Commands
|
|
2496
|
+
// from older apps carry no stamp and get the nonce check alone.
|
|
2497
|
+
const seenNonces = new Map(); // nonce, hex -> seen at
|
|
2498
|
+
const NONCE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
2499
|
+
const NONCE_CAP = 20_000;
|
|
2500
|
+
const COMMAND_MAX_SKEW_MS = 10 * 60 * 1000;
|
|
2501
|
+
function freshCommand(b64, cmd, now = Date.now()) {
|
|
2502
|
+
const nonce = Buffer.from(b64, 'base64').subarray(0, 12).toString('hex');
|
|
2503
|
+
if (seenNonces.has(nonce)) return false;
|
|
2504
|
+
if (typeof cmd?.at === 'number' && Math.abs(now - cmd.at) > COMMAND_MAX_SKEW_MS) return false;
|
|
2505
|
+
seenNonces.set(nonce, now);
|
|
2506
|
+
// Oldest first, so trimming stops at the first one worth keeping.
|
|
2507
|
+
for (const [k, t] of seenNonces) {
|
|
2508
|
+
if (now - t > NONCE_TTL_MS || seenNonces.size > NONCE_CAP) seenNonces.delete(k);
|
|
2509
|
+
else break;
|
|
2510
|
+
}
|
|
2511
|
+
return true;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2433
2514
|
let lastPongAt = Date.now();
|
|
2515
|
+
let connectedAt = 0;
|
|
2516
|
+
let takeovers = 0; // in a row; reset once a connection has held for a while
|
|
2517
|
+
|
|
2518
|
+
/// How long to stay off the relay after another host took the channel.
|
|
2519
|
+
/// Exiting instead, as this did up to 0.1.11, hands the job to launchd, which
|
|
2520
|
+
/// starts us again five seconds later; we take the channel back, the other
|
|
2521
|
+
/// host takes it back, and round it goes. One Mac logged 1,123 restarts that
|
|
2522
|
+
/// way, in bursts of ten, against another machine sharing its key.
|
|
2523
|
+
function takeoverBackoffMs(n) {
|
|
2524
|
+
return Math.min(15 * 60_000, 60_000 * 2 ** Math.max(0, n - 1));
|
|
2525
|
+
}
|
|
2434
2526
|
|
|
2435
2527
|
function connect() {
|
|
2436
2528
|
// handshakeTimeout: a dial that never completes would otherwise sit in
|
|
2437
2529
|
// CONNECTING forever without firing close, freezing the retry loop.
|
|
2438
2530
|
ws = new WebSocket(`${RELAY}/ch/${CHANNEL}?role=host`, { handshakeTimeout: 15_000 });
|
|
2439
2531
|
lastPongAt = Date.now();
|
|
2440
|
-
ws.on('open', () => {
|
|
2532
|
+
ws.on('open', () => {
|
|
2533
|
+
lastPongAt = Date.now();
|
|
2534
|
+
connectedAt = Date.now();
|
|
2535
|
+
console.log('[daemon] connected to relay');
|
|
2536
|
+
ensureApprovalSocket();
|
|
2537
|
+
sendSnapshot(true);
|
|
2538
|
+
});
|
|
2441
2539
|
ws.on('pong', () => { lastPongAt = Date.now(); });
|
|
2442
2540
|
ws.on('message', (data) => {
|
|
2443
2541
|
const str = data.toString();
|
|
@@ -2452,14 +2550,30 @@ function connect() {
|
|
|
2452
2550
|
// which is what the approval policy checks before holding a prompt.
|
|
2453
2551
|
try {
|
|
2454
2552
|
const cmd = open(str);
|
|
2553
|
+
if (!freshCommand(str, cmd)) {
|
|
2554
|
+
const off = typeof cmd?.at === 'number' ? Math.round((Date.now() - cmd.at) / 60_000) : 0;
|
|
2555
|
+
console.log(`[daemon] dropped a repeated or stale ${cmd?.type || 'command'}`
|
|
2556
|
+
+ (Math.abs(off) >= 10 ? ` (stamped ${off} min off this Mac's clock)` : ''));
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2455
2559
|
notePhoneSeen();
|
|
2456
2560
|
handleCommand(cmd)?.catch?.(() => {});
|
|
2457
2561
|
} catch (e) { console.log('[daemon] bad message (wrong key?)'); }
|
|
2458
2562
|
});
|
|
2459
2563
|
ws.on('close', (code) => {
|
|
2460
2564
|
if (code === 4001) {
|
|
2461
|
-
|
|
2462
|
-
|
|
2565
|
+
// A copy someone started by hand should get out of the way and say why.
|
|
2566
|
+
if (!runsFromServiceDir) {
|
|
2567
|
+
console.log('[daemon] another daemon took over this channel, exiting. Run only one copy.');
|
|
2568
|
+
process.exit(0);
|
|
2569
|
+
}
|
|
2570
|
+
if (connectedAt && Date.now() - connectedAt > 10 * 60_000) takeovers = 0;
|
|
2571
|
+
takeovers++;
|
|
2572
|
+
const wait = takeoverBackoffMs(takeovers);
|
|
2573
|
+
console.log(`[daemon] another host took over this pairing (${takeovers} in a row), trying again in ${Math.round(wait / 60_000)} min. `
|
|
2574
|
+
+ 'Another Mac or a second copy is using this key; remove it there with: npx @clastres/groundcontrol service uninstall');
|
|
2575
|
+
setTimeout(connect, wait);
|
|
2576
|
+
return;
|
|
2463
2577
|
}
|
|
2464
2578
|
console.log('[daemon] relay disconnected, retrying in 3s');
|
|
2465
2579
|
setTimeout(connect, 3000);
|
|
@@ -2483,6 +2597,7 @@ for (const cmd of hookState.foreign || []) console.log(`[daemon] another app als
|
|
|
2483
2597
|
startApprovalServer();
|
|
2484
2598
|
setInterval(releaseAsksIfBack, 3000);
|
|
2485
2599
|
setInterval(releaseAnsweredOnMac, 2000);
|
|
2600
|
+
setInterval(expireWatches, 30_000);
|
|
2486
2601
|
|
|
2487
2602
|
// Self update. Staggered off startup so a login storm does not hit the
|
|
2488
2603
|
// registry at once, then re-checked hourly (the daily stamp does the gating).
|
|
@@ -2531,4 +2646,3 @@ function watchProviderDir(dir, cache, label) {
|
|
|
2531
2646
|
watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin() || 'not found yet, rechecked on each prompt'})`);
|
|
2532
2647
|
watchProviderDir(path.join(KIMI_DIR, 'sessions'), kimiCache, 'kimi sessions');
|
|
2533
2648
|
watchProviderDir(path.join(KIMICODE_DIR, 'sessions'), kimiCache, 'kimi code sessions');
|
|
2534
|
-
watchProviderDir(GROKBOT_DIR, grokbotCache, 'grok bot roster (read only, replies happen in the Grok Bot app)');
|