@clastres/groundcontrol 0.1.11 → 0.1.13
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 +448 -65
- package/hook.mjs +3 -0
- package/package.json +1 -1
- package/test/questions.mjs +67 -0
- package/test/review-fixes.mjs +339 -0
package/daemon.mjs
CHANGED
|
@@ -15,14 +15,14 @@ import net from 'node:net';
|
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import readline from 'node:readline';
|
|
18
|
-
import { spawn, 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.13';
|
|
26
26
|
const PACKAGE = '@clastres/groundcontrol';
|
|
27
27
|
|
|
28
28
|
// ---------- config / pairing ----------
|
|
@@ -84,6 +84,16 @@ const LOG_FILE = path.join(CONF_DIR, 'daemon.log');
|
|
|
84
84
|
const THIS_FILE = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
85
85
|
const runsFromServiceDir = THIS_FILE.startsWith(SERVICE_DIR + path.sep);
|
|
86
86
|
|
|
87
|
+
// The service log had no times, so a run of "stale relay link" lines could not
|
|
88
|
+
// be told apart: a Mac waking from sleep, or a link failing every minute.
|
|
89
|
+
// Only daemon lines, and only in the service, so the setup output stays clean.
|
|
90
|
+
if (runsFromServiceDir) {
|
|
91
|
+
const plain = console.log.bind(console);
|
|
92
|
+
console.log = (first, ...rest) => (typeof first === 'string' && first.startsWith('[daemon]')
|
|
93
|
+
? plain(`${new Date().toISOString()} ${first}`, ...rest)
|
|
94
|
+
: plain(first, ...rest));
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
const launchctl = (args) => {
|
|
88
98
|
try { return execSync(`launchctl ${args}`, { stdio: 'pipe' }).toString(); }
|
|
89
99
|
catch (e) { return (e.stdout?.toString() || '') + (e.stderr?.toString() || ''); }
|
|
@@ -558,8 +568,41 @@ const pairString = `groundcontrol://pair#${pairPayload}`;
|
|
|
558
568
|
console.log('\n GROUND CONTROL daemon');
|
|
559
569
|
console.log(` relay: ${RELAY} (phone will use: ${relayForPhone})`);
|
|
560
570
|
console.log(` channel: ${CHANNEL}\n`);
|
|
561
|
-
|
|
562
|
-
|
|
571
|
+
// The pairing code and the QR both carry the key itself. They are for a person
|
|
572
|
+
// at a terminal. Anywhere else stdout is a file, the launchd service's is
|
|
573
|
+
// daemon.log, and a log is the thing people paste into a bug report.
|
|
574
|
+
if (subcommand === 'qr' || process.stdout.isTTY) {
|
|
575
|
+
qrcode.generate(pairString, { small: true });
|
|
576
|
+
console.log(`\n Pairing code (paste into the app):\n\n ${pairString}\n`);
|
|
577
|
+
} else {
|
|
578
|
+
console.log(' Pairing code hidden here. Show it with: npx @clastres/groundcontrol qr\n');
|
|
579
|
+
}
|
|
580
|
+
if (runsFromServiceDir) scrubLogSecrets();
|
|
581
|
+
|
|
582
|
+
/// Versions up to 0.1.11 printed the pairing code and its QR into daemon.log
|
|
583
|
+
/// on every start, and the log was readable by every account on the Mac.
|
|
584
|
+
/// Take both out of the existing log and close the permissions. Rewritten in
|
|
585
|
+
/// place rather than replaced: launchd holds the file open for appending, and
|
|
586
|
+
/// a new file would leave it writing into one that no longer exists.
|
|
587
|
+
function scrubLogSecrets() {
|
|
588
|
+
for (const f of [LOG_FILE, path.join(CONF_DIR, 'daemon.err.log')]) {
|
|
589
|
+
try { fs.chmodSync(f, 0o600); } catch {}
|
|
590
|
+
}
|
|
591
|
+
try { fs.chmodSync(CONF_DIR, 0o700); } catch {}
|
|
592
|
+
try {
|
|
593
|
+
const raw = fs.readFileSync(LOG_FILE, 'utf8');
|
|
594
|
+
// A real code, not the [removed] marker left by an earlier pass, so a
|
|
595
|
+
// clean log is read once per start and never rewritten.
|
|
596
|
+
const code = /groundcontrol:\/\/pair#[A-Za-z0-9_-]/;
|
|
597
|
+
const qrRow = /^[█▀▄ ]{20,}$/;
|
|
598
|
+
if (!code.test(raw) && !/^[█▀▄ ]{20,}$/m.test(raw)) return;
|
|
599
|
+
const clean = raw.split('\n')
|
|
600
|
+
.filter((line) => !qrRow.test(line))
|
|
601
|
+
.join('\n')
|
|
602
|
+
.replace(/groundcontrol:\/\/pair#[A-Za-z0-9_-]+/g, 'groundcontrol://pair#[removed]');
|
|
603
|
+
fs.writeFileSync(LOG_FILE, clean);
|
|
604
|
+
} catch {}
|
|
605
|
+
}
|
|
563
606
|
|
|
564
607
|
if (subcommand === 'qr') process.exit(0);
|
|
565
608
|
|
|
@@ -778,16 +821,20 @@ function canInject(tty) {
|
|
|
778
821
|
}
|
|
779
822
|
|
|
780
823
|
// Returns the method used, or null if no terminal path was found.
|
|
781
|
-
async function
|
|
824
|
+
async function injectNow(tty, text) {
|
|
782
825
|
const dev = `/dev/${tty}`;
|
|
783
826
|
const apps = terminalApps();
|
|
784
827
|
const clean = text.replace(/\r?\n/g, ' ').trim();
|
|
785
828
|
|
|
786
829
|
if (apps.tmuxPanes.has(dev)) {
|
|
830
|
+
// No shell in between. Phone text used to go through execSync inside
|
|
831
|
+
// double quotes, where backticks and $(...) still run, so "rename `foo`"
|
|
832
|
+
// executed foo on this Mac before the agent ever saw the prompt.
|
|
833
|
+
const pane = apps.tmuxPanes.get(dev);
|
|
787
834
|
try {
|
|
788
|
-
|
|
835
|
+
execFileSync('tmux', ['send-keys', '-t', pane, '-l', '--', clean], { stdio: 'ignore' });
|
|
789
836
|
await sleep(150);
|
|
790
|
-
|
|
837
|
+
execFileSync('tmux', ['send-keys', '-t', pane, 'Enter'], { stdio: 'ignore' });
|
|
791
838
|
return 'tmux';
|
|
792
839
|
} catch {}
|
|
793
840
|
}
|
|
@@ -833,6 +880,18 @@ end tell`);
|
|
|
833
880
|
return null;
|
|
834
881
|
}
|
|
835
882
|
|
|
883
|
+
// One injection per terminal at a time. Each is two writes with a pause
|
|
884
|
+
// between them (the text, then Enter), so two prompts landing together would
|
|
885
|
+
// interleave into one run on prompt and a stray Enter.
|
|
886
|
+
const injecting = new Map(); // tty -> tail of its queue
|
|
887
|
+
function injectPrompt(tty, text) {
|
|
888
|
+
const run = (injecting.get(tty) || Promise.resolve()).then(() => injectNow(tty, text));
|
|
889
|
+
const tail = run.catch(() => null);
|
|
890
|
+
injecting.set(tty, tail);
|
|
891
|
+
tail.then(() => { if (injecting.get(tty) === tail) injecting.delete(tty); });
|
|
892
|
+
return run;
|
|
893
|
+
}
|
|
894
|
+
|
|
836
895
|
const headlessActive = new Set(); // sessionIds with a headless --resume in flight
|
|
837
896
|
|
|
838
897
|
const transcriptCache = new Map(); // sessionId -> file path
|
|
@@ -889,7 +948,9 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
|
|
|
889
948
|
if (role !== 'user' && role !== 'assistant') continue;
|
|
890
949
|
const text = extractText(e.message).trim();
|
|
891
950
|
if (!text || /^(<system-reminder>|<command-name>|<local-command|<command-message|\[Request interrupted|\[SYSTEM NOTIFICATION)/.test(text)) continue;
|
|
892
|
-
|
|
951
|
+
// uuid is Claude Code's own id for the line, stable for as long as the
|
|
952
|
+
// journal lives. The phone keys its rows on it; text is no identity.
|
|
953
|
+
out.push({ role, text: text.slice(0, 4000), ts: e.timestamp || null, ...(e.uuid ? { id: e.uuid } : {}) });
|
|
893
954
|
}
|
|
894
955
|
return out.slice(-limit);
|
|
895
956
|
}
|
|
@@ -1469,8 +1530,63 @@ const askPayload = (ask, queued) => ({
|
|
|
1469
1530
|
detail: ask.detail,
|
|
1470
1531
|
at: ask.at,
|
|
1471
1532
|
queued,
|
|
1533
|
+
// ALWAYS only means something when Claude Code offered a rule to save.
|
|
1534
|
+
// Apps before 0.1.11 ignore this and always show the button.
|
|
1535
|
+
canAlways: ask.suggestions.length > 0,
|
|
1536
|
+
...(ask.questions ? { questions: ask.questions } : {}),
|
|
1472
1537
|
});
|
|
1473
1538
|
|
|
1539
|
+
/// AskUserQuestion is a picker, not a yes or no. Allowing it without answers
|
|
1540
|
+
/// lets Claude carry on with nothing chosen, so the phone gets the questions
|
|
1541
|
+
/// themselves and answers them through updatedInput. Clipped, because every
|
|
1542
|
+
/// byte rides the relay and lands on a phone screen.
|
|
1543
|
+
function questionsOf(tool, input) {
|
|
1544
|
+
if (tool !== 'AskUserQuestion' || !Array.isArray(input?.questions)) return null;
|
|
1545
|
+
const qs = questionSources(input)
|
|
1546
|
+
.map((q) => ({
|
|
1547
|
+
question: q.question.slice(0, 600),
|
|
1548
|
+
header: typeof q.header === 'string' ? q.header.slice(0, 40) : '',
|
|
1549
|
+
multiSelect: q.multiSelect === true,
|
|
1550
|
+
// choice is the default; text and number have no options to tap.
|
|
1551
|
+
kind: typeof q.kind === 'string' ? q.kind : 'choice',
|
|
1552
|
+
options: (Array.isArray(q.options) ? q.options : [])
|
|
1553
|
+
.filter((o) => o && typeof o.label === 'string' && o.label)
|
|
1554
|
+
.slice(0, 8)
|
|
1555
|
+
.map((o) => ({
|
|
1556
|
+
label: o.label.slice(0, 200),
|
|
1557
|
+
description: typeof o.description === 'string' ? o.description.slice(0, 300) : '',
|
|
1558
|
+
})),
|
|
1559
|
+
}));
|
|
1560
|
+
return qs.length ? qs : null;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/// The questions as Claude Code wrote them, in the order the phone sees them.
|
|
1564
|
+
/// Answers go back keyed by these full texts: the phone only ever sees a
|
|
1565
|
+
/// clipped copy, and Claude looks each answer up by the exact question.
|
|
1566
|
+
function questionSources(input) {
|
|
1567
|
+
return (Array.isArray(input?.questions) ? input.questions : [])
|
|
1568
|
+
.filter((q) => q && typeof q.question === 'string' && q.question)
|
|
1569
|
+
.slice(0, 6);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/// The phone's picks, checked against the questions it was shown. Keys are
|
|
1573
|
+
/// the question text, which is how Claude Code looks them up; a multi select
|
|
1574
|
+
/// arrives as several labels and goes back joined, the way Claude's own
|
|
1575
|
+
/// picker reports it.
|
|
1576
|
+
function answersFor(ask, raw) {
|
|
1577
|
+
if (!ask.questions || !raw || typeof raw !== 'object') return null;
|
|
1578
|
+
const full = questionSources(ask.input);
|
|
1579
|
+
const out = {};
|
|
1580
|
+
ask.questions.forEach((q, i) => {
|
|
1581
|
+
const v = raw[q.question];
|
|
1582
|
+
const picked = (Array.isArray(v) ? v : [v])
|
|
1583
|
+
.filter((x) => typeof x === 'string' && x.trim())
|
|
1584
|
+
.map((x) => x.trim().slice(0, 2000));
|
|
1585
|
+
if (picked.length) out[full[i]?.question ?? q.question] = picked.join(', ');
|
|
1586
|
+
});
|
|
1587
|
+
return Object.keys(out).length ? out : null;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1474
1590
|
function readSessions() {
|
|
1475
1591
|
const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
|
|
1476
1592
|
const known = new Set(sessions.map((s) => s.id));
|
|
@@ -1569,6 +1685,8 @@ function hostState() {
|
|
|
1569
1685
|
const bustHostCache = () => { hostCache.at = 0; };
|
|
1570
1686
|
|
|
1571
1687
|
let approvalServerUp = false;
|
|
1688
|
+
let approvalServer = null;
|
|
1689
|
+
let approvalSocketIno = 0; // which file at ASK_SOCKET is ours
|
|
1572
1690
|
let availableUpdate = null; // version string once a newer release is seen
|
|
1573
1691
|
|
|
1574
1692
|
const pendingAsks = new Map(); // askId -> ask record
|
|
@@ -1652,6 +1770,12 @@ function summarizeTool(tool, input) {
|
|
|
1652
1770
|
return { line: clip(i.url, 300), detail: clip(i.prompt, 300) };
|
|
1653
1771
|
case 'WebSearch':
|
|
1654
1772
|
return { line: clip(i.query, 300), detail: '' };
|
|
1773
|
+
case 'AskUserQuestion': {
|
|
1774
|
+
// The questions travel on their own (questionsOf); the line is just the
|
|
1775
|
+
// first one, for older apps and the crew list.
|
|
1776
|
+
const qs = Array.isArray(i.questions) ? i.questions : [];
|
|
1777
|
+
return { line: clip(qs[0]?.question || 'Claude has a question', 300), detail: '' };
|
|
1778
|
+
}
|
|
1655
1779
|
default: {
|
|
1656
1780
|
// Unknown or MCP tools: show whichever field reads most like a target,
|
|
1657
1781
|
// and fall back to the whole input.
|
|
@@ -1677,6 +1801,13 @@ function settleAsk(ask, decision, outcome, note) {
|
|
|
1677
1801
|
ask.done = true;
|
|
1678
1802
|
clearTimeout(ask.holdTimer);
|
|
1679
1803
|
dropAsk(ask);
|
|
1804
|
+
// A hook that is already gone cannot take the answer. Saying "allowed" to
|
|
1805
|
+
// the phone then would be a lie: the session never got it.
|
|
1806
|
+
if (decision && (ask.socket.destroyed || !ask.socket.writable)) {
|
|
1807
|
+
outcome = 'gone';
|
|
1808
|
+
note = 'the session stopped waiting before your answer arrived';
|
|
1809
|
+
decision = null;
|
|
1810
|
+
}
|
|
1680
1811
|
try { ask.socket.write(JSON.stringify({ decision: decision || null }) + '\n'); } catch {}
|
|
1681
1812
|
try { ask.socket.end(); } catch {}
|
|
1682
1813
|
console.log(`[daemon] permission ${ask.tool} ${outcome}${note ? `: ${note}` : ''}`);
|
|
@@ -1701,6 +1832,14 @@ function onPermissionRequest(req, socket) {
|
|
|
1701
1832
|
tool: req.toolName || 'tool',
|
|
1702
1833
|
line,
|
|
1703
1834
|
detail,
|
|
1835
|
+
// Kept whole for AskUserQuestion, whose answer is the same input with the
|
|
1836
|
+
// answers added. Never sent to the phone as is.
|
|
1837
|
+
input: req.toolInput && typeof req.toolInput === 'object' ? req.toolInput : {},
|
|
1838
|
+
questions: questionsOf(req.toolName, req.toolInput),
|
|
1839
|
+
// What Claude Code would save if you picked "don't ask again" on the Mac.
|
|
1840
|
+
// ALWAYS on the phone sends these back as updatedPermissions; without
|
|
1841
|
+
// them there is no rule to save, so the button is hidden.
|
|
1842
|
+
suggestions: Array.isArray(req.permissionSuggestions) ? req.permissionSuggestions : [],
|
|
1704
1843
|
cwd: req.cwd || '',
|
|
1705
1844
|
at: Date.now(),
|
|
1706
1845
|
socket,
|
|
@@ -1768,12 +1907,33 @@ function startApprovalServer(attempt = 0) {
|
|
|
1768
1907
|
});
|
|
1769
1908
|
server.listen(ASK_SOCKET, () => {
|
|
1770
1909
|
try { fs.chmodSync(ASK_SOCKET, 0o600); } catch {}
|
|
1910
|
+
try { approvalSocketIno = fs.statSync(ASK_SOCKET).ino; } catch {}
|
|
1911
|
+
approvalServer = server;
|
|
1771
1912
|
approvalServerUp = true;
|
|
1772
1913
|
bustHostCache();
|
|
1773
1914
|
console.log(`[daemon] remote approvals ready (mode: ${approvalConfig().mode})`);
|
|
1774
1915
|
});
|
|
1775
1916
|
}
|
|
1776
1917
|
|
|
1918
|
+
/// A second copy started on this Mac unlinks the approvals socket and binds
|
|
1919
|
+
/// its own. When that copy then loses the relay back to this one and exits,
|
|
1920
|
+
/// the hook is left calling a file nobody listens on, and approvals stop
|
|
1921
|
+
/// reaching the phone until the next restart. Up to 0.1.11 the restart came
|
|
1922
|
+
/// quickly, because a displaced daemon exited; now that it waits instead,
|
|
1923
|
+
/// it has to take the socket back itself, every time the relay is ours again.
|
|
1924
|
+
function ensureApprovalSocket() {
|
|
1925
|
+
if (!approvalServer) return;
|
|
1926
|
+
let ino = 0;
|
|
1927
|
+
try { ino = fs.statSync(ASK_SOCKET).ino; } catch {}
|
|
1928
|
+
if (ino && ino === approvalSocketIno) return;
|
|
1929
|
+
console.log('[daemon] another copy replaced the approvals socket, taking it back');
|
|
1930
|
+
try { approvalServer.close(); } catch {}
|
|
1931
|
+
approvalServer = null;
|
|
1932
|
+
approvalServerUp = false;
|
|
1933
|
+
bustHostCache();
|
|
1934
|
+
startApprovalServer();
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1777
1937
|
// Claude Code's own status for each session, as last seen on disk.
|
|
1778
1938
|
const lastRawStatus = new Map(); // sessionId -> 'busy' | 'idle' | 'waiting' | ...
|
|
1779
1939
|
|
|
@@ -1828,6 +1988,9 @@ function releaseAnsweredOnMac() {
|
|
|
1828
1988
|
function releaseAsksIfBack() {
|
|
1829
1989
|
if (!pendingAsks.size) return;
|
|
1830
1990
|
const cfg = approvalConfig();
|
|
1991
|
+
// `groundcontrol approvals off` runs in another process; this is where the
|
|
1992
|
+
// daemon notices it and hands back whatever it was still holding.
|
|
1993
|
+
if (cfg.mode === 'off') return releaseAllAsks('remote approvals were turned off');
|
|
1831
1994
|
if (cfg.mode !== 'away') return;
|
|
1832
1995
|
if (macIdleSeconds() >= cfg.awayAfterSeconds) return;
|
|
1833
1996
|
for (const ask of [...pendingAsks.values()]) {
|
|
@@ -1857,8 +2020,11 @@ function isNewer(candidate, current) {
|
|
|
1857
2020
|
}
|
|
1858
2021
|
|
|
1859
2022
|
async function latestPublishedVersion() {
|
|
2023
|
+
// Plain JSON. The abbreviated install-v1 format only exists for the whole
|
|
2024
|
+
// package document; asked of /latest the registry answers 406, and it did,
|
|
2025
|
+
// on every check from 0.1.7 through 0.1.11, so no install ever updated.
|
|
1860
2026
|
const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
|
|
1861
|
-
headers: { accept: 'application/
|
|
2027
|
+
headers: { accept: 'application/json' },
|
|
1862
2028
|
signal: AbortSignal.timeout(15_000),
|
|
1863
2029
|
});
|
|
1864
2030
|
if (!res.ok) throw new Error(`registry ${res.status}`);
|
|
@@ -1906,9 +2072,9 @@ async function checkForUpdate({ force = false } = {}) {
|
|
|
1906
2072
|
|
|
1907
2073
|
/// A daemon shutting down must hand every held request back rather than leave
|
|
1908
2074
|
/// sessions blocked on a socket that will never answer.
|
|
1909
|
-
function releaseAllAsks() {
|
|
2075
|
+
function releaseAllAsks(note = 'the daemon stopped') {
|
|
1910
2076
|
for (const ask of [...pendingAsks.values()]) {
|
|
1911
|
-
settleAsk(ask, null, 'released',
|
|
2077
|
+
settleAsk(ask, null, 'released', note);
|
|
1912
2078
|
}
|
|
1913
2079
|
}
|
|
1914
2080
|
|
|
@@ -1917,6 +2083,17 @@ function releaseAllAsks() {
|
|
|
1917
2083
|
let ws = null;
|
|
1918
2084
|
let lastSnapshotJson = '';
|
|
1919
2085
|
|
|
2086
|
+
/// Everything bound for the phone goes through here. The socket is replaced
|
|
2087
|
+
/// on every reconnect, and ws throws, rather than returning, when asked to
|
|
2088
|
+
/// send on one that is still connecting. From a file watcher or a child
|
|
2089
|
+
/// process callback that is an uncaught exception, which takes the daemon and
|
|
2090
|
+
/// every held approval down with it. The relay keeps no queue, so a message
|
|
2091
|
+
/// with no open socket is lost either way; the phone has timeouts for that.
|
|
2092
|
+
function toPhone(obj) {
|
|
2093
|
+
if (ws?.readyState !== 1) return false;
|
|
2094
|
+
try { ws.send(seal(obj)); return true; } catch { return false; }
|
|
2095
|
+
}
|
|
2096
|
+
|
|
1920
2097
|
function sendSnapshot(force = false) {
|
|
1921
2098
|
// A parser tripping on a malformed journal must never take the daemon down:
|
|
1922
2099
|
// this runs on a bare interval with nothing above it to catch a throw.
|
|
@@ -1926,10 +2103,19 @@ function sendSnapshot(force = false) {
|
|
|
1926
2103
|
return;
|
|
1927
2104
|
}
|
|
1928
2105
|
for (const s of sessions) if (s.rawStatus) lastRawStatus.set(s.id, s.rawStatus);
|
|
2106
|
+
// A session that left the fleet takes its status with it. Kept, an idle from
|
|
2107
|
+
// its last turn would read as "answered on the Mac" for the next request.
|
|
2108
|
+
const present = new Set(sessions.map((s) => s.id));
|
|
2109
|
+
for (const id of lastRawStatus.keys()) if (!present.has(id)) lastRawStatus.delete(id);
|
|
1929
2110
|
const host = hostState();
|
|
1930
2111
|
// Host state joins the dedupe key, otherwise flipping approvals on would not
|
|
1931
|
-
// reach a phone whose session list happened not to change.
|
|
1932
|
-
|
|
2112
|
+
// reach a phone whose session list happened not to change. The last message
|
|
2113
|
+
// is keyed on its time, role and length as well as its opening: two replies
|
|
2114
|
+
// that start alike would otherwise look like one and the second never sent.
|
|
2115
|
+
const json = JSON.stringify([sessions.map(({ lastMessage, ...s }) => ({
|
|
2116
|
+
...s,
|
|
2117
|
+
last: lastMessage && [lastMessage.ts, lastMessage.role, lastMessage.text?.length, lastMessage.text?.slice(0, 120)],
|
|
2118
|
+
})), host]);
|
|
1933
2119
|
if (!force && json === lastSnapshotJson) return;
|
|
1934
2120
|
lastSnapshotJson = json;
|
|
1935
2121
|
if (ws?.readyState === 1) {
|
|
@@ -1940,37 +2126,131 @@ function sendSnapshot(force = false) {
|
|
|
1940
2126
|
|
|
1941
2127
|
// Live transcript streaming: while a phone has a chat open, push transcript
|
|
1942
2128
|
// updates the moment the JSONL file changes.
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
2129
|
+
//
|
|
2130
|
+
// Every open chat holds a lease on its watch. Apps from 0.1.9 name themselves
|
|
2131
|
+
// and renew every minute, so one phone closing a chat no longer stops it
|
|
2132
|
+
// streaming on another, and a phone that vanishes without an unwatch (killed
|
|
2133
|
+
// app, dead network) stops costing this Mac a file watch plus a full
|
|
2134
|
+
// transcript read on every write within minutes instead of never. Older apps
|
|
2135
|
+
// never renew, so theirs is long and renewed whenever they ask for the log.
|
|
2136
|
+
const WATCH_LEASE_MS = 3 * 60_000;
|
|
2137
|
+
const LEGACY_LEASE_MS = 30 * 60_000;
|
|
2138
|
+
const PROMPT_LEASE_MS = 5 * 60_000;
|
|
2139
|
+
const leaseFor = (holder) => (holder === 'legacy' ? LEGACY_LEASE_MS : holder === 'prompt' ? PROMPT_LEASE_MS : WATCH_LEASE_MS);
|
|
2140
|
+
const holderOf = (cmd) => (typeof cmd.client === 'string' && cmd.client ? `phone:${cmd.client.slice(0, 64)}` : 'legacy');
|
|
2141
|
+
const watchers = new Map(); // sessionId -> { watcher, debounce, leases: Map<holder, expiresAt> }
|
|
2142
|
+
// Sessions whose legacy lease ran out rather than being unwatched: the chat
|
|
2143
|
+
// may well still be open on an older phone, which asks for the log again
|
|
2144
|
+
// whenever the session changes state. That is the moment to resume.
|
|
2145
|
+
const lapsedLegacy = new Map(); // sessionId -> when the lease ran out
|
|
2146
|
+
const LAPSED_FORGET_MS = 24 * 60 * 60 * 1000;
|
|
2147
|
+
|
|
2148
|
+
/// Start or renew a watch. Returns 'created', 'renewed', or null when there
|
|
2149
|
+
/// is no transcript to watch.
|
|
2150
|
+
function watchTranscript(sessionId, holder = 'legacy', now = Date.now()) {
|
|
2151
|
+
if (holder === 'legacy') lapsedLegacy.delete(sessionId);
|
|
2152
|
+
const until = now + leaseFor(holder);
|
|
2153
|
+
const entry = watchers.get(sessionId);
|
|
2154
|
+
// A journal that was replaced or moved leaves the old watch listening to a
|
|
2155
|
+
// file nobody writes. Renewals are the moment to notice and start over.
|
|
2156
|
+
if (entry && !watchStale(sessionId, entry)) {
|
|
2157
|
+
entry.leases.set(holder, Math.max(until, entry.leases.get(holder) || 0));
|
|
2158
|
+
return 'renewed';
|
|
2159
|
+
}
|
|
2160
|
+
const leases = entry ? entry.leases : new Map();
|
|
2161
|
+
if (entry) closeWatch(sessionId, entry);
|
|
2162
|
+
leases.set(holder, Math.max(until, leases.get(holder) || 0));
|
|
1946
2163
|
const file = findTranscript(sessionId);
|
|
1947
|
-
if (!file) return;
|
|
1948
|
-
|
|
2164
|
+
if (!file) return null;
|
|
2165
|
+
const next = { watcher: null, debounce: null, firstAt: 0, file, ino: inodeOf(file), leases };
|
|
2166
|
+
const push = () => {
|
|
2167
|
+
clearTimeout(next.debounce);
|
|
2168
|
+
next.debounce = null;
|
|
2169
|
+
next.firstAt = 0;
|
|
2170
|
+
toPhone({ type: 'transcript', sessionId, messages: readMessages(sessionId) });
|
|
2171
|
+
};
|
|
1949
2172
|
try {
|
|
1950
|
-
// Journals are appended to, so watching the file is enough.
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2173
|
+
// Journals are appended to, so watching the file is enough. Debounced so
|
|
2174
|
+
// a burst of writes is one read, but never for longer than a second: a
|
|
2175
|
+
// turn that keeps writing would otherwise reach the phone only once it
|
|
2176
|
+
// stopped, which is the chat that looks frozen while the Mac is busy.
|
|
2177
|
+
next.watcher = fs.watch(file, () => {
|
|
2178
|
+
const t = Date.now();
|
|
2179
|
+
if (!next.firstAt) next.firstAt = t;
|
|
2180
|
+
clearTimeout(next.debounce);
|
|
2181
|
+
if (t - next.firstAt >= TRANSCRIPT_MAX_WAIT_MS) return push();
|
|
2182
|
+
next.debounce = setTimeout(push, 250);
|
|
1956
2183
|
});
|
|
1957
|
-
|
|
2184
|
+
next.watcher.on?.('error', () => closeWatch(sessionId, next, { keepIntent: true }));
|
|
2185
|
+
watchers.set(sessionId, next);
|
|
1958
2186
|
console.log(`[daemon] watching transcript ${sessionId.slice(0, 8)}`);
|
|
1959
|
-
|
|
2187
|
+
return 'created';
|
|
2188
|
+
} catch { return null; }
|
|
1960
2189
|
}
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
2190
|
+
|
|
2191
|
+
const TRANSCRIPT_MAX_WAIT_MS = 1000;
|
|
2192
|
+
const inodeOf = (file) => { try { return fs.statSync(file).ino; } catch { return 0; } };
|
|
2193
|
+
function watchStale(sessionId, entry) {
|
|
2194
|
+
if (!entry.watcher) return true;
|
|
2195
|
+
const file = findTranscript(sessionId);
|
|
2196
|
+
return !!file && (file !== entry.file || inodeOf(file) !== entry.ino);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
/// Keep an existing lease alive without starting a watch nobody asked for,
|
|
2200
|
+
/// except the one an older app's open chat lost to the clock.
|
|
2201
|
+
function renewWatch(sessionId, holder, now = Date.now()) {
|
|
2202
|
+
const entry = watchers.get(sessionId);
|
|
2203
|
+
if (entry?.leases.has(holder)) {
|
|
2204
|
+
if (watchStale(sessionId, entry)) watchTranscript(sessionId, holder, now);
|
|
2205
|
+
else entry.leases.set(holder, now + leaseFor(holder));
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
if (holder === 'legacy' && lapsedLegacy.has(sessionId)) watchTranscript(sessionId, 'legacy', now);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function unwatchTranscript(sessionId, holder = 'legacy') {
|
|
2212
|
+
if (holder === 'legacy') lapsedLegacy.delete(sessionId);
|
|
2213
|
+
const entry = watchers.get(sessionId);
|
|
2214
|
+
if (!entry) return;
|
|
2215
|
+
entry.leases.delete(holder);
|
|
2216
|
+
if (!entry.leases.size) closeWatch(sessionId, entry);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/// keepIntent leaves the leases in place with no watcher behind them, so the
|
|
2220
|
+
/// next renewal or getTranscript starts a fresh one instead of the chat going
|
|
2221
|
+
/// quiet for good after one watcher error.
|
|
2222
|
+
function closeWatch(sessionId, entry, { keepIntent = false } = {}) {
|
|
2223
|
+
clearTimeout(entry.debounce);
|
|
2224
|
+
try { entry.watcher?.close(); } catch {}
|
|
2225
|
+
entry.watcher = null;
|
|
2226
|
+
if (!keepIntent && watchers.get(sessionId) === entry) watchers.delete(sessionId);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function expireWatches(now = Date.now()) {
|
|
2230
|
+
for (const [sessionId, entry] of watchers) {
|
|
2231
|
+
for (const [holder, until] of entry.leases) {
|
|
2232
|
+
if (until > now) continue;
|
|
2233
|
+
entry.leases.delete(holder);
|
|
2234
|
+
if (holder === 'legacy') lapsedLegacy.set(sessionId, now);
|
|
2235
|
+
}
|
|
2236
|
+
if (!entry.leases.size) closeWatch(sessionId, entry);
|
|
2237
|
+
}
|
|
2238
|
+
for (const [sessionId, at] of lapsedLegacy) if (now - at > LAPSED_FORGET_MS) lapsedLegacy.delete(sessionId);
|
|
1964
2239
|
}
|
|
1965
2240
|
|
|
1966
2241
|
async function handleCommand(cmd) {
|
|
1967
2242
|
if (cmd.type === 'getTranscript' && cmd.sessionId) {
|
|
1968
|
-
|
|
2243
|
+
renewWatch(cmd.sessionId, holderOf(cmd));
|
|
2244
|
+
toPhone({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) });
|
|
1969
2245
|
} else if (cmd.type === 'watch' && cmd.sessionId) {
|
|
1970
|
-
watchTranscript(cmd.sessionId);
|
|
1971
|
-
|
|
2246
|
+
const how = watchTranscript(cmd.sessionId, holderOf(cmd));
|
|
2247
|
+
// A renewal only keeps the lease; the phone already has the log, unless
|
|
2248
|
+
// this daemon restarted since and the watch had to be started again.
|
|
2249
|
+
if (!cmd.renew || how === 'created') {
|
|
2250
|
+
toPhone({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) });
|
|
2251
|
+
}
|
|
1972
2252
|
} else if (cmd.type === 'unwatch' && cmd.sessionId) {
|
|
1973
|
-
unwatchTranscript(cmd.sessionId);
|
|
2253
|
+
unwatchTranscript(cmd.sessionId, holderOf(cmd));
|
|
1974
2254
|
} else if (cmd.type === 'setApprovals') {
|
|
1975
2255
|
// A deliberate tap in the app, with the app explaining what it adds, is
|
|
1976
2256
|
// the consent for this. A backup is still written before any change.
|
|
@@ -1978,6 +2258,8 @@ async function handleCommand(cmd) {
|
|
|
1978
2258
|
if (cmd.enabled === false) {
|
|
1979
2259
|
writeApprovalConfig({ mode: 'off' });
|
|
1980
2260
|
result = uninstallApprovalHook();
|
|
2261
|
+
// Off means off for what is already held too, not just what comes next.
|
|
2262
|
+
releaseAllAsks('remote approvals were turned off');
|
|
1981
2263
|
console.log('[daemon] remote approvals turned off from the phone');
|
|
1982
2264
|
} else {
|
|
1983
2265
|
const mode = ['away', 'always'].includes(cmd.mode) ? cmd.mode : 'away';
|
|
@@ -1987,44 +2269,65 @@ async function handleCommand(cmd) {
|
|
|
1987
2269
|
}
|
|
1988
2270
|
bustHostCache();
|
|
1989
2271
|
sendSnapshot(true);
|
|
1990
|
-
|
|
2272
|
+
toPhone({
|
|
1991
2273
|
type: 'approvalsResult',
|
|
1992
2274
|
ok: !!result.ok,
|
|
1993
2275
|
error: result.ok ? '' : (result.reason || 'could not change the setting'),
|
|
1994
2276
|
host: hostState(),
|
|
1995
|
-
})
|
|
1996
|
-
} else if (cmd.type === 'approve' || cmd.type === 'deny') {
|
|
2277
|
+
});
|
|
2278
|
+
} else if (cmd.type === 'approve' || cmd.type === 'deny' || cmd.type === 'answer') {
|
|
1997
2279
|
const ask = cmd.askId ? pendingAsks.get(cmd.askId) : null;
|
|
1998
2280
|
if (!ask) {
|
|
1999
2281
|
// Answered on the Mac, expired, or the session moved on while the tap
|
|
2000
|
-
// was in flight. Tell the phone so the card clears instead of hanging
|
|
2001
|
-
|
|
2282
|
+
// was in flight. Tell the phone so the card clears instead of hanging,
|
|
2283
|
+
// and send the fleet with it: the phone draws the card from the
|
|
2284
|
+
// snapshot, and a deduped one would never come.
|
|
2285
|
+
toPhone({
|
|
2002
2286
|
type: 'askResolved', askId: cmd.askId || '', sessionId: cmd.sessionId || '',
|
|
2003
2287
|
outcome: 'gone', note: 'that request is no longer waiting',
|
|
2004
|
-
})
|
|
2288
|
+
});
|
|
2289
|
+
sendSnapshot(true);
|
|
2290
|
+
return;
|
|
2005
2291
|
}
|
|
2006
|
-
if (cmd.type === '
|
|
2007
|
-
|
|
2008
|
-
|
|
2292
|
+
if (cmd.type === 'answer') {
|
|
2293
|
+
const answers = answersFor(ask, cmd.answers);
|
|
2294
|
+
if (!answers) {
|
|
2295
|
+
// Nothing usable picked. Leave the request held; the card stays up.
|
|
2296
|
+
return toPhone({ type: 'askResolved', askId: ask.id, sessionId: ask.sessionId, outcome: 'invalid', note: 'pick an answer first' });
|
|
2297
|
+
}
|
|
2298
|
+
settleAsk(ask, { behavior: 'allow', updatedInput: { ...ask.input, answers } },
|
|
2299
|
+
'answered', 'from the phone');
|
|
2300
|
+
} else if (cmd.type === 'approve' && ask.questions) {
|
|
2301
|
+
// An app too old to show the choices can only say yes, and yes with no
|
|
2302
|
+
// answer is a question skipped. Hand it back so it gets a real answer.
|
|
2303
|
+
settleAsk(ask, null, 'released', 'this needs a choice, update the app or pick it');
|
|
2304
|
+
} else if (cmd.type === 'approve') {
|
|
2305
|
+
// Claude Code reads only behavior, updatedInput and updatedPermissions.
|
|
2306
|
+
// Up to 0.1.12 ALWAYS sent alwaysAllow, which it drops, so the next
|
|
2307
|
+
// matching call asked again. The suggestions are the exact rules it
|
|
2308
|
+
// would have saved from its own "don't ask again" option.
|
|
2309
|
+
const always = cmd.always === true && ask.suggestions.length > 0;
|
|
2310
|
+
settleAsk(ask, { behavior: 'allow', ...(always ? { updatedPermissions: ask.suggestions } : {}) },
|
|
2311
|
+
'allowed', always ? 'from the phone, and always from now on' : 'from the phone');
|
|
2009
2312
|
} else {
|
|
2010
2313
|
const reason = (cmd.text || '').trim();
|
|
2011
2314
|
settleAsk(ask, { behavior: 'deny', message: reason || 'Denied from the Ground Control app.' },
|
|
2012
2315
|
'denied', reason ? `from the phone: ${reason.slice(0, 60)}` : 'from the phone');
|
|
2013
2316
|
}
|
|
2014
2317
|
} else if (cmd.type === 'projects') {
|
|
2015
|
-
|
|
2318
|
+
toPhone({
|
|
2016
2319
|
type: 'projects',
|
|
2017
2320
|
projects: knownProjects().map((p) => ({ path: p.path, name: p.name, at: p.at })),
|
|
2018
2321
|
providers: launchProviders(),
|
|
2019
|
-
})
|
|
2322
|
+
});
|
|
2020
2323
|
} else if (cmd.type === 'launch') {
|
|
2021
2324
|
await launchSession(cmd);
|
|
2022
2325
|
} else if (cmd.type === 'ping' && cmd.sessionId) {
|
|
2023
2326
|
const s = readSessions().find((x) => x.id === cmd.sessionId);
|
|
2024
|
-
|
|
2327
|
+
toPhone({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() });
|
|
2025
2328
|
} else if (cmd.type === 'prompt' && cmd.sessionId && cmd.text) {
|
|
2026
2329
|
const session = readSessions().find((s) => s.id === cmd.sessionId);
|
|
2027
|
-
if (!session) return
|
|
2330
|
+
if (!session) return toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' });
|
|
2028
2331
|
|
|
2029
2332
|
if (session.provider && session.provider !== 'claude') return promptHeadless(session, cmd.text);
|
|
2030
2333
|
|
|
@@ -2033,27 +2336,27 @@ async function handleCommand(cmd) {
|
|
|
2033
2336
|
const method = await injectPrompt(session.tty, cmd.text);
|
|
2034
2337
|
if (method) {
|
|
2035
2338
|
console.log(`[daemon] injected prompt into ${session.name} via ${method}`);
|
|
2036
|
-
|
|
2037
|
-
watchTranscript(cmd.sessionId);
|
|
2339
|
+
toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: true, text: '' });
|
|
2340
|
+
watchTranscript(cmd.sessionId, 'prompt');
|
|
2038
2341
|
sendSnapshot(true);
|
|
2039
2342
|
return;
|
|
2040
2343
|
}
|
|
2041
2344
|
if (automationDenied) {
|
|
2042
2345
|
console.log('[daemon] prompt blocked: macOS Automation permission missing');
|
|
2043
|
-
return
|
|
2346
|
+
return toPhone({
|
|
2044
2347
|
type: 'promptResult', sessionId: cmd.sessionId, ok: false,
|
|
2045
2348
|
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.',
|
|
2046
|
-
})
|
|
2349
|
+
});
|
|
2047
2350
|
}
|
|
2048
2351
|
}
|
|
2049
|
-
if (session.status === 'busy') return
|
|
2352
|
+
if (session.status === 'busy') return toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session is busy, wait for it to go idle' });
|
|
2050
2353
|
const bin = claudeBin();
|
|
2051
2354
|
if (!bin) {
|
|
2052
2355
|
console.log('[daemon] prompt failed: claude binary not found on PATH');
|
|
2053
|
-
return
|
|
2356
|
+
return toPhone({
|
|
2054
2357
|
type: 'promptResult', sessionId: cmd.sessionId, ok: false,
|
|
2055
2358
|
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',
|
|
2056
|
-
})
|
|
2359
|
+
});
|
|
2057
2360
|
}
|
|
2058
2361
|
console.log(`[daemon] prompt -> ${session.name}: ${cmd.text.slice(0, 60)}`);
|
|
2059
2362
|
headlessActive.add(cmd.sessionId);
|
|
@@ -2069,11 +2372,13 @@ async function handleCommand(cmd) {
|
|
|
2069
2372
|
if (responded) return;
|
|
2070
2373
|
responded = true;
|
|
2071
2374
|
headlessActive.delete(cmd.sessionId);
|
|
2072
|
-
|
|
2375
|
+
toPhone(payload);
|
|
2073
2376
|
sendSnapshot(true);
|
|
2074
2377
|
};
|
|
2075
2378
|
child.stdout.on('data', (d) => { out += d; });
|
|
2076
2379
|
child.stderr.on('data', (d) => { err += d; });
|
|
2380
|
+
// Delivered is not done: the turn can run for minutes. See promptAccepted.
|
|
2381
|
+
child.on('spawn', () => promptAccepted(cmd.sessionId));
|
|
2077
2382
|
child.on('error', (e) => {
|
|
2078
2383
|
console.log(`[daemon] claude spawn failed: ${e.message}`);
|
|
2079
2384
|
respond({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: `could not run claude: ${e.message}` });
|
|
@@ -2082,6 +2387,7 @@ async function handleCommand(cmd) {
|
|
|
2082
2387
|
respond({
|
|
2083
2388
|
type: 'promptResult', sessionId: cmd.sessionId, ok: code === 0,
|
|
2084
2389
|
text: (out || err).trim().slice(0, 4000),
|
|
2390
|
+
...(code === 0 ? {} : { error: failureText('claude', code, err || out) }),
|
|
2085
2391
|
});
|
|
2086
2392
|
});
|
|
2087
2393
|
}
|
|
@@ -2110,15 +2416,15 @@ function bustProviderCaches() {
|
|
|
2110
2416
|
function promptHeadless(session, text) {
|
|
2111
2417
|
// Anything but idle means another writer owns the session right now: busy
|
|
2112
2418
|
// is a running turn, waiting is a human approval the phone cannot answer.
|
|
2113
|
-
if (session.status === 'busy') return
|
|
2114
|
-
if (session.status !== 'idle') return
|
|
2419
|
+
if (session.status === 'busy') return toPhone({ type: 'promptResult', sessionId: session.id, ok: false, error: 'session is busy, wait for it to go idle' });
|
|
2420
|
+
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' });
|
|
2115
2421
|
const spec = headlessPromptSpec[session.provider]?.();
|
|
2116
2422
|
if (!spec) {
|
|
2117
2423
|
console.log(`[daemon] ${session.provider} prompt failed: binary not found`);
|
|
2118
|
-
return
|
|
2424
|
+
return toPhone({
|
|
2119
2425
|
type: 'promptResult', sessionId: session.id, ok: false,
|
|
2120
2426
|
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.`,
|
|
2121
|
-
})
|
|
2427
|
+
});
|
|
2122
2428
|
}
|
|
2123
2429
|
console.log(`[daemon] prompt -> ${session.provider} ${session.name}: ${text.slice(0, 60)}`);
|
|
2124
2430
|
const promptedAt = Date.now();
|
|
@@ -2137,11 +2443,12 @@ function promptHeadless(session, text) {
|
|
|
2137
2443
|
responded = true;
|
|
2138
2444
|
headlessActive.delete(session.id);
|
|
2139
2445
|
bustProviderCaches();
|
|
2140
|
-
|
|
2446
|
+
toPhone(payload);
|
|
2141
2447
|
sendSnapshot(true);
|
|
2142
2448
|
};
|
|
2143
2449
|
child.stderr.on('data', (d) => { err += d; });
|
|
2144
2450
|
child.stdout.on('data', () => {});
|
|
2451
|
+
child.on('spawn', () => promptAccepted(session.id));
|
|
2145
2452
|
child.on('error', (e) => {
|
|
2146
2453
|
console.log(`[daemon] ${session.provider} spawn failed: ${e.message}`);
|
|
2147
2454
|
respond({ type: 'promptResult', sessionId: session.id, ok: false, error: `could not run ${session.provider}: ${e.message}` });
|
|
@@ -2156,9 +2463,26 @@ function promptHeadless(session, text) {
|
|
|
2156
2463
|
respond({
|
|
2157
2464
|
type: 'promptResult', sessionId: session.id, ok: code === 0,
|
|
2158
2465
|
text: code === 0 ? (reply?.text || '').slice(0, 4000) : err.trim().slice(0, 4000),
|
|
2466
|
+
...(code === 0 ? {} : { error: failureText(session.provider, code, err) }),
|
|
2159
2467
|
});
|
|
2160
2468
|
});
|
|
2161
|
-
watchTranscript(session.id);
|
|
2469
|
+
watchTranscript(session.id, 'prompt');
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/// Tell the phone a headless run has started. The run answers only when the
|
|
2473
|
+
/// whole turn is over, often minutes later, and the app gives up on a prompt
|
|
2474
|
+
/// after 30 seconds: up to 0.1.8 it then showed "no reply from mac yet" and
|
|
2475
|
+
/// kept showing it after the run succeeded. An ok now clears the pending
|
|
2476
|
+
/// prompt; a failure later still arrives as its own result.
|
|
2477
|
+
function promptAccepted(sessionId) {
|
|
2478
|
+
toPhone({ type: 'promptResult', sessionId, ok: true, text: '', accepted: true });
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
/// A failed run's last words, for the phone. Whatever the agent printed on
|
|
2482
|
+
/// stderr last is usually the reason; the exit code is the fallback.
|
|
2483
|
+
function failureText(provider, code, output) {
|
|
2484
|
+
const tail = String(output || '').trim().split('\n').filter(Boolean).slice(-3).join(' ').slice(0, 300);
|
|
2485
|
+
return tail ? `${provider} stopped: ${tail}` : `${provider} exited with code ${code}`;
|
|
2162
2486
|
}
|
|
2163
2487
|
|
|
2164
2488
|
// ---------- launching a new pilot ----------
|
|
@@ -2259,7 +2583,7 @@ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
|
2259
2583
|
let lastLaunchAt = 0;
|
|
2260
2584
|
|
|
2261
2585
|
async function launchSession(cmd) {
|
|
2262
|
-
const fail = (error) =>
|
|
2586
|
+
const fail = (error) => toPhone({ type: 'launchResult', ok: false, error });
|
|
2263
2587
|
|
|
2264
2588
|
const providers = launchProviders();
|
|
2265
2589
|
const provider = cmd.provider || 'claude';
|
|
@@ -2317,18 +2641,60 @@ end tell`);
|
|
|
2317
2641
|
// The new session reaches the island through the same discovery every other
|
|
2318
2642
|
// one does, which takes a few seconds. Say so rather than implying the pilot
|
|
2319
2643
|
// is already there.
|
|
2320
|
-
|
|
2644
|
+
toPhone({ type: 'launchResult', ok: true, cwd: target.path, note: `${provider} is starting in ${target.name}` });
|
|
2321
2645
|
sendSnapshot(true);
|
|
2322
2646
|
}
|
|
2323
2647
|
|
|
2648
|
+
// Replay protection. The relay only ever sees ciphertext, but it could send
|
|
2649
|
+
// a captured command again, and it would decrypt just fine. Every sealed
|
|
2650
|
+
// message opens with a random 12 byte nonce, so one seen twice is a replay.
|
|
2651
|
+
// Keyed on the decoded bytes, not the text: base64 decoding skips whitespace
|
|
2652
|
+
// and takes either alphabet, so one frame can be spelled many ways.
|
|
2653
|
+
// Apps from 0.1.9 also stamp each command with when it was sent, which turns
|
|
2654
|
+
// away old captures even after a restart has emptied this cache. Commands
|
|
2655
|
+
// from older apps carry no stamp and get the nonce check alone.
|
|
2656
|
+
const seenNonces = new Map(); // nonce, hex -> seen at
|
|
2657
|
+
const NONCE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
2658
|
+
const NONCE_CAP = 20_000;
|
|
2659
|
+
const COMMAND_MAX_SKEW_MS = 10 * 60 * 1000;
|
|
2660
|
+
function freshCommand(b64, cmd, now = Date.now()) {
|
|
2661
|
+
const nonce = Buffer.from(b64, 'base64').subarray(0, 12).toString('hex');
|
|
2662
|
+
if (seenNonces.has(nonce)) return false;
|
|
2663
|
+
if (typeof cmd?.at === 'number' && Math.abs(now - cmd.at) > COMMAND_MAX_SKEW_MS) return false;
|
|
2664
|
+
seenNonces.set(nonce, now);
|
|
2665
|
+
// Oldest first, so trimming stops at the first one worth keeping.
|
|
2666
|
+
for (const [k, t] of seenNonces) {
|
|
2667
|
+
if (now - t > NONCE_TTL_MS || seenNonces.size > NONCE_CAP) seenNonces.delete(k);
|
|
2668
|
+
else break;
|
|
2669
|
+
}
|
|
2670
|
+
return true;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2324
2673
|
let lastPongAt = Date.now();
|
|
2674
|
+
let connectedAt = 0;
|
|
2675
|
+
let takeovers = 0; // in a row; reset once a connection has held for a while
|
|
2676
|
+
|
|
2677
|
+
/// How long to stay off the relay after another host took the channel.
|
|
2678
|
+
/// Exiting instead, as this did up to 0.1.11, hands the job to launchd, which
|
|
2679
|
+
/// starts us again five seconds later; we take the channel back, the other
|
|
2680
|
+
/// host takes it back, and round it goes. One Mac logged 1,123 restarts that
|
|
2681
|
+
/// way, in bursts of ten, against another machine sharing its key.
|
|
2682
|
+
function takeoverBackoffMs(n) {
|
|
2683
|
+
return Math.min(15 * 60_000, 60_000 * 2 ** Math.max(0, n - 1));
|
|
2684
|
+
}
|
|
2325
2685
|
|
|
2326
2686
|
function connect() {
|
|
2327
2687
|
// handshakeTimeout: a dial that never completes would otherwise sit in
|
|
2328
2688
|
// CONNECTING forever without firing close, freezing the retry loop.
|
|
2329
2689
|
ws = new WebSocket(`${RELAY}/ch/${CHANNEL}?role=host`, { handshakeTimeout: 15_000 });
|
|
2330
2690
|
lastPongAt = Date.now();
|
|
2331
|
-
ws.on('open', () => {
|
|
2691
|
+
ws.on('open', () => {
|
|
2692
|
+
lastPongAt = Date.now();
|
|
2693
|
+
connectedAt = Date.now();
|
|
2694
|
+
console.log('[daemon] connected to relay');
|
|
2695
|
+
ensureApprovalSocket();
|
|
2696
|
+
sendSnapshot(true);
|
|
2697
|
+
});
|
|
2332
2698
|
ws.on('pong', () => { lastPongAt = Date.now(); });
|
|
2333
2699
|
ws.on('message', (data) => {
|
|
2334
2700
|
const str = data.toString();
|
|
@@ -2343,14 +2709,30 @@ function connect() {
|
|
|
2343
2709
|
// which is what the approval policy checks before holding a prompt.
|
|
2344
2710
|
try {
|
|
2345
2711
|
const cmd = open(str);
|
|
2712
|
+
if (!freshCommand(str, cmd)) {
|
|
2713
|
+
const off = typeof cmd?.at === 'number' ? Math.round((Date.now() - cmd.at) / 60_000) : 0;
|
|
2714
|
+
console.log(`[daemon] dropped a repeated or stale ${cmd?.type || 'command'}`
|
|
2715
|
+
+ (Math.abs(off) >= 10 ? ` (stamped ${off} min off this Mac's clock)` : ''));
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2346
2718
|
notePhoneSeen();
|
|
2347
2719
|
handleCommand(cmd)?.catch?.(() => {});
|
|
2348
2720
|
} catch (e) { console.log('[daemon] bad message (wrong key?)'); }
|
|
2349
2721
|
});
|
|
2350
2722
|
ws.on('close', (code) => {
|
|
2351
2723
|
if (code === 4001) {
|
|
2352
|
-
|
|
2353
|
-
|
|
2724
|
+
// A copy someone started by hand should get out of the way and say why.
|
|
2725
|
+
if (!runsFromServiceDir) {
|
|
2726
|
+
console.log('[daemon] another daemon took over this channel, exiting. Run only one copy.');
|
|
2727
|
+
process.exit(0);
|
|
2728
|
+
}
|
|
2729
|
+
if (connectedAt && Date.now() - connectedAt > 10 * 60_000) takeovers = 0;
|
|
2730
|
+
takeovers++;
|
|
2731
|
+
const wait = takeoverBackoffMs(takeovers);
|
|
2732
|
+
console.log(`[daemon] another host took over this pairing (${takeovers} in a row), trying again in ${Math.round(wait / 60_000)} min. `
|
|
2733
|
+
+ 'Another Mac or a second copy is using this key; remove it there with: npx @clastres/groundcontrol service uninstall');
|
|
2734
|
+
setTimeout(connect, wait);
|
|
2735
|
+
return;
|
|
2354
2736
|
}
|
|
2355
2737
|
console.log('[daemon] relay disconnected, retrying in 3s');
|
|
2356
2738
|
setTimeout(connect, 3000);
|
|
@@ -2374,6 +2756,7 @@ for (const cmd of hookState.foreign || []) console.log(`[daemon] another app als
|
|
|
2374
2756
|
startApprovalServer();
|
|
2375
2757
|
setInterval(releaseAsksIfBack, 3000);
|
|
2376
2758
|
setInterval(releaseAnsweredOnMac, 2000);
|
|
2759
|
+
setInterval(expireWatches, 30_000);
|
|
2377
2760
|
|
|
2378
2761
|
// Self update. Staggered off startup so a login storm does not hit the
|
|
2379
2762
|
// registry at once, then re-checked hourly (the daily stamp does the gating).
|
package/hook.mjs
CHANGED
|
@@ -92,6 +92,9 @@ sock.on('connect', () => {
|
|
|
92
92
|
cwd: payload.cwd || '',
|
|
93
93
|
permissionMode: payload.permission_mode || '',
|
|
94
94
|
permissionContext: payload.permission_context || null,
|
|
95
|
+
// The rules Claude Code would save for "don't ask again". ALWAYS on the
|
|
96
|
+
// phone hands these back; without them it has nothing to save.
|
|
97
|
+
permissionSuggestions: Array.isArray(payload.permission_suggestions) ? payload.permission_suggestions : [],
|
|
95
98
|
}) + '\n');
|
|
96
99
|
});
|
|
97
100
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
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"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Claude's question picker (AskUserQuestion) reaches the phone as questions,
|
|
2
|
+
// and the phone's picks go back as the answers Claude Code reads from
|
|
3
|
+
// updatedInput. Before this, the phone showed ALLOW and DENY for a question,
|
|
4
|
+
// and allowing it let Claude carry on with nothing chosen.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
|
|
10
|
+
const src = fs.readFileSync(DAEMON, 'utf8');
|
|
11
|
+
const start = src.indexOf('/// The phone-facing shape of a held request.');
|
|
12
|
+
const end = src.indexOf('function readSessions() {');
|
|
13
|
+
if (start < 0 || end < 0 || end < start) throw new Error('could not locate the question helpers in daemon.mjs');
|
|
14
|
+
|
|
15
|
+
let failures = 0;
|
|
16
|
+
const check = (label, cond, detail = '') => {
|
|
17
|
+
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
18
|
+
if (!cond) failures++;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const { askPayload, questionsOf, answersFor } = new Function(
|
|
22
|
+
`${src.slice(start, end)}\n return { askPayload, questionsOf, answersFor };`,
|
|
23
|
+
)();
|
|
24
|
+
|
|
25
|
+
const input = {
|
|
26
|
+
questions: [
|
|
27
|
+
{ question: 'Which database?', header: 'DB', multiSelect: false,
|
|
28
|
+
options: [{ label: 'Postgres', description: 'the usual' }, { label: 'SQLite' }] },
|
|
29
|
+
{ question: 'Which extras?', header: 'Extras', multiSelect: true,
|
|
30
|
+
options: [{ label: 'Auth' }, { label: 'Billing' }, { label: 'Search' }] },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const qs = questionsOf('AskUserQuestion', input);
|
|
35
|
+
check('a question picker becomes questions for the phone', qs?.length === 2 && qs[0].options.length === 2);
|
|
36
|
+
check('options keep their descriptions', qs[0].options[0].description === 'the usual');
|
|
37
|
+
check('multi select survives', qs[1].multiSelect === true);
|
|
38
|
+
check('any other tool has no questions', questionsOf('Bash', { command: 'ls' }) === null);
|
|
39
|
+
check('a malformed input has none either', questionsOf('AskUserQuestion', { questions: 'nope' }) === null);
|
|
40
|
+
|
|
41
|
+
const ask = { id: 'a1', tool: 'AskUserQuestion', line: 'Which database?', detail: '', at: 1, input, questions: qs, suggestions: [] };
|
|
42
|
+
const payload = askPayload(ask, 1);
|
|
43
|
+
check('the payload carries the questions', payload.questions === qs);
|
|
44
|
+
check('and no ALWAYS without a rule to save', payload.canAlways === false);
|
|
45
|
+
check('a request with a suggested rule offers ALWAYS',
|
|
46
|
+
askPayload({ ...ask, questions: null, suggestions: [{ type: 'addRules' }] }, 1).canAlways === true);
|
|
47
|
+
check('a plain request sends no questions key', !('questions' in askPayload({ ...ask, questions: null }, 1)));
|
|
48
|
+
|
|
49
|
+
const answers = answersFor(ask, { 'Which database?': 'Postgres', 'Which extras?': ['Auth', 'Search'] });
|
|
50
|
+
check('a single pick answers by question text', answers?.['Which database?'] === 'Postgres');
|
|
51
|
+
check('several picks go back joined', answers?.['Which extras?'] === 'Auth, Search');
|
|
52
|
+
check('a question the phone was never shown is ignored',
|
|
53
|
+
!('Other?' in (answersFor(ask, { 'Which database?': 'SQLite', 'Other?': 'x' }) || {})));
|
|
54
|
+
check('nothing picked is no answer at all', answersFor(ask, { 'Which database?': ' ' }) === null);
|
|
55
|
+
const long = 'Why? '.repeat(200);
|
|
56
|
+
const longInput = { questions: [{ question: long, options: [{ label: 'Because' }] }] };
|
|
57
|
+
const longQs = questionsOf('AskUserQuestion', longInput);
|
|
58
|
+
const longAnswers = answersFor({ input: longInput, questions: longQs }, { [longQs[0].question]: 'Because' });
|
|
59
|
+
check('a long question is clipped for the phone', longQs[0].question.length === 600);
|
|
60
|
+
check('but answered under its full text, which is what Claude looks up', longAnswers?.[long] === 'Because');
|
|
61
|
+
check('free text is taken as is', answersFor(ask, { 'Which database?': 'MySQL, honestly' })?.['Which database?'] === 'MySQL, honestly');
|
|
62
|
+
|
|
63
|
+
if (failures) {
|
|
64
|
+
console.log(`\n${failures} check(s) failed`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
console.log('\nall checks passed');
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// The fixes from the 2026-09-22 review, each pinned to the failure it fixes.
|
|
2
|
+
// Same approach as the other files here: lift the real function out of
|
|
3
|
+
// daemon.mjs and run it against fakes, so what is tested is what ships.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
|
|
11
|
+
const src = fs.readFileSync(DAEMON, 'utf8');
|
|
12
|
+
|
|
13
|
+
/// The source between two markers, which must both exist and be in order.
|
|
14
|
+
function slice(from, to) {
|
|
15
|
+
const start = src.indexOf(from);
|
|
16
|
+
const end = src.indexOf(to, start + 1);
|
|
17
|
+
if (start < 0 || end < 0) throw new Error(`could not locate ${from.slice(0, 50)} in daemon.mjs`);
|
|
18
|
+
return src.slice(start, end);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let failures = 0;
|
|
22
|
+
const check = (label, cond, detail = '') => {
|
|
23
|
+
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
|
|
24
|
+
if (!cond) failures++;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ---------- phone text never reaches a shell ----------
|
|
28
|
+
{
|
|
29
|
+
const calls = [];
|
|
30
|
+
const { injectPrompt } = new Function(
|
|
31
|
+
'terminalApps', 'execFileSync', 'execSync', 'sleep', 'osascript', 'escapeAS',
|
|
32
|
+
`${slice('// Returns the method used, or null if no terminal path was found.', 'const headlessActive = new Set();')}
|
|
33
|
+
return { injectPrompt };`,
|
|
34
|
+
)(
|
|
35
|
+
() => ({ tmuxPanes: new Map([['/dev/ttys009', '%3']]), terminal: false, iterm: false }),
|
|
36
|
+
(file, args) => { calls.push({ file, args }); return Buffer.from(''); },
|
|
37
|
+
(cmd) => { calls.push({ shell: cmd }); return Buffer.from(''); },
|
|
38
|
+
async () => {},
|
|
39
|
+
async () => 'notfound',
|
|
40
|
+
(s) => s,
|
|
41
|
+
);
|
|
42
|
+
const text = 'rename `foo` to $(whoami) and keep $HOME';
|
|
43
|
+
const method = await injectPrompt('ttys009', text);
|
|
44
|
+
check('a tmux session is typed into through tmux', method === 'tmux', `got ${method}`);
|
|
45
|
+
check('nothing goes through a shell', calls.every((c) => !c.shell), JSON.stringify(calls.filter((c) => c.shell)));
|
|
46
|
+
const typed = calls.find((c) => c.args?.includes('-l'));
|
|
47
|
+
check('the prompt arrives as one literal argument, backticks and all',
|
|
48
|
+
typed?.file === 'tmux' && typed.args.at(-1) === text, JSON.stringify(typed));
|
|
49
|
+
const dashed = [];
|
|
50
|
+
await new Function(
|
|
51
|
+
'terminalApps', 'execFileSync', 'execSync', 'sleep', 'osascript', 'escapeAS',
|
|
52
|
+
`${slice('// Returns the method used, or null if no terminal path was found.', 'const headlessActive = new Set();')}
|
|
53
|
+
return injectPrompt;`,
|
|
54
|
+
)(
|
|
55
|
+
() => ({ tmuxPanes: new Map([['/dev/ttys009', '%3']]), terminal: false, iterm: false }),
|
|
56
|
+
(file, args) => { dashed.push(args); return Buffer.from(''); },
|
|
57
|
+
() => { throw new Error('shell'); },
|
|
58
|
+
async () => {}, async () => 'notfound', (s) => s,
|
|
59
|
+
)('ttys009', '-t is not a flag here');
|
|
60
|
+
// Two prompts at once into one terminal must not interleave.
|
|
61
|
+
const order = [];
|
|
62
|
+
const serial = new Function(
|
|
63
|
+
'terminalApps', 'execFileSync', 'execSync', 'sleep', 'osascript', 'escapeAS',
|
|
64
|
+
`${slice('// Returns the method used, or null if no terminal path was found.', 'const headlessActive = new Set();')}
|
|
65
|
+
return injectPrompt;`,
|
|
66
|
+
)(
|
|
67
|
+
() => ({ tmuxPanes: new Map([['/dev/ttys009', '%3']]), terminal: false, iterm: false }),
|
|
68
|
+
(file, args) => { order.push(args.includes('-l') ? args.at(-1) : 'Enter'); return Buffer.from(''); },
|
|
69
|
+
() => { throw new Error('shell'); },
|
|
70
|
+
() => new Promise((r) => setTimeout(r, 20)), async () => 'notfound', (s) => s,
|
|
71
|
+
);
|
|
72
|
+
await Promise.all([serial('ttys009', 'FIRST'), serial('ttys009', 'SECOND')]);
|
|
73
|
+
check('two prompts into one terminal go in one after the other',
|
|
74
|
+
order.join(',') === 'FIRST,Enter,SECOND,Enter', order.join(','));
|
|
75
|
+
check('a prompt starting with a dash is not read as a tmux option',
|
|
76
|
+
dashed[0]?.indexOf('--') === dashed[0]?.length - 2, JSON.stringify(dashed[0]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- sends while reconnecting do not throw ----------
|
|
80
|
+
{
|
|
81
|
+
const run = (ws) => new Function('ws', 'seal',
|
|
82
|
+
`${slice('/// Everything bound for the phone goes through here.', 'function sendSnapshot(')}
|
|
83
|
+
return toPhone;`)(ws, (o) => JSON.stringify(o));
|
|
84
|
+
const connecting = { readyState: 0, send() { throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); } };
|
|
85
|
+
let threw = false;
|
|
86
|
+
let sent;
|
|
87
|
+
try { sent = run(connecting)({ type: 'transcript' }); } catch { threw = true; }
|
|
88
|
+
check('a send while the next socket is still connecting does not throw', !threw && sent === false);
|
|
89
|
+
check('a send with no socket at all is dropped quietly', run(null)({ type: 'x' }) === false);
|
|
90
|
+
const out = [];
|
|
91
|
+
check('an open socket still gets the message', run({ readyState: 1, send: (d) => out.push(d) })({ type: 'x' }) === true && out.length === 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------- self update asks npm a question it answers ----------
|
|
95
|
+
{
|
|
96
|
+
const requests = [];
|
|
97
|
+
// The registry's real behaviour: the abbreviated document only exists for
|
|
98
|
+
// the package root, so /latest with that accept header is a 406.
|
|
99
|
+
const fakeFetch = async (url, opts) => {
|
|
100
|
+
requests.push({ url, accept: opts?.headers?.accept });
|
|
101
|
+
if (/\/latest$/.test(url) && String(opts?.headers?.accept).includes('vnd.npm.install-v1')) {
|
|
102
|
+
return { ok: false, status: 406, json: async () => ({}) };
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, status: 200, json: async () => ({ name: '@clastres/groundcontrol', version: '0.1.12' }) };
|
|
105
|
+
};
|
|
106
|
+
const latest = new Function('fetch', 'PACKAGE',
|
|
107
|
+
`${slice('async function latestPublishedVersion() {', '/// Re-install the service from the newly published version.')}
|
|
108
|
+
return latestPublishedVersion;`)(fakeFetch, '@clastres/groundcontrol');
|
|
109
|
+
let version = null;
|
|
110
|
+
let error = null;
|
|
111
|
+
try { version = await latest(); } catch (e) { error = e.message; }
|
|
112
|
+
check('the update check gets a version back', version === '0.1.12', error || '');
|
|
113
|
+
check('it asks the latest endpoint of the right package',
|
|
114
|
+
requests[0]?.url === 'https://registry.npmjs.org/@clastres/groundcontrol/latest', requests[0]?.url);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------- a takeover backs off instead of restarting ----------
|
|
118
|
+
{
|
|
119
|
+
const backoff = new Function(`${slice('/// How long to stay off the relay', 'function connect() {')} return takeoverBackoffMs;`)();
|
|
120
|
+
const mins = [1, 2, 3, 4, 5, 6, 9].map((n) => backoff(n) / 60_000);
|
|
121
|
+
check('the first takeover waits a minute, then doubles', mins[0] === 1 && mins[1] === 2 && mins[2] === 4 && mins[3] === 8, mins.join(','));
|
|
122
|
+
check('and never waits more than fifteen minutes', mins.slice(4).every((m) => m === 15), mins.join(','));
|
|
123
|
+
const closeHandler = slice(" ws.on('close', (code) => {", " ws.on('error', () => {});");
|
|
124
|
+
check('the service copy no longer exits on a takeover',
|
|
125
|
+
/if \(!runsFromServiceDir\) \{[\s\S]*process\.exit\(0\);[\s\S]*\}[\s\S]*setTimeout\(connect, wait\)/.test(closeHandler));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------- replayed and stale commands are refused ----------
|
|
129
|
+
{
|
|
130
|
+
const fresh = new Function(`${slice('// Replay protection.', 'let lastPongAt = Date.now();')} return freshCommand;`)();
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
const frame = (tag) => `${tag.padEnd(16, 'A')}restOfTheCiphertext==`;
|
|
133
|
+
check('a new command is accepted', fresh(frame('n1'), { type: 'prompt', at: now }, now));
|
|
134
|
+
check('the same frame again is a replay', !fresh(frame('n1'), { type: 'prompt', at: now }, now + 1000));
|
|
135
|
+
check('a stamped command from twenty minutes ago is stale', !fresh(frame('n2'), { type: 'prompt', at: now - 20 * 60_000 }, now));
|
|
136
|
+
check('a clock a little ahead is fine', fresh(frame('n3'), { type: 'prompt', at: now + 60_000 }, now));
|
|
137
|
+
check('an older app with no stamp still gets through once', fresh(frame('n4'), { type: 'watch' }, now));
|
|
138
|
+
check('but not twice', !fresh(frame('n4'), { type: 'watch' }, now));
|
|
139
|
+
|
|
140
|
+
// The relay can respell a frame: base64 decoding skips whitespace and takes
|
|
141
|
+
// either alphabet. Every spelling must still count as the same frame.
|
|
142
|
+
const key = crypto.randomBytes(32);
|
|
143
|
+
const open = new Function('key', 'crypto', 'Buffer',
|
|
144
|
+
`${slice('function open(b64) {', '// ---------- environment hardening ----------')} return open;`)(key, crypto, Buffer);
|
|
145
|
+
const seal = (obj) => {
|
|
146
|
+
const nonce = crypto.randomBytes(12);
|
|
147
|
+
const c = crypto.createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
|
|
148
|
+
const ct = Buffer.concat([c.update(JSON.stringify(obj)), c.final()]);
|
|
149
|
+
return Buffer.concat([nonce, ct, c.getAuthTag()]).toString('base64');
|
|
150
|
+
};
|
|
151
|
+
const real = seal({ type: 'prompt', sessionId: 's', text: 'hi', at: now });
|
|
152
|
+
const spellings = [` ${real}`, `\n${real}`, `${real.slice(0, 8)} ${real.slice(8)}`, real.replace(/\+/g, '-').replace(/\//g, '_')];
|
|
153
|
+
check('a real frame is accepted', fresh(real, open(real), now));
|
|
154
|
+
for (const alt of spellings) {
|
|
155
|
+
let decoded = null;
|
|
156
|
+
try { decoded = open(alt); } catch {}
|
|
157
|
+
check(`respelled ${JSON.stringify(alt.slice(0, 3))}... still decrypts, and is still a replay`, decoded?.text === 'hi' && !fresh(alt, decoded, now + 5));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------- the pairing key stays out of the log ----------
|
|
162
|
+
{
|
|
163
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-scrub-'));
|
|
164
|
+
const LOG_FILE = path.join(dir, 'daemon.log');
|
|
165
|
+
const qrRow = '█ ▄▄▄▄▄ █ █▄▀▀█▄█ ▀█ ▄▀█ █▀ ▄█▄▄ █▀▄█ ▄▄▄▄▄ █';
|
|
166
|
+
fs.writeFileSync(LOG_FILE, [
|
|
167
|
+
'[daemon] connected to relay',
|
|
168
|
+
'▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄',
|
|
169
|
+
qrRow,
|
|
170
|
+
' Pairing code (paste into the app):',
|
|
171
|
+
' groundcontrol://pair#eyJ2IjoxLCJrIjoic2VjcmV0In0',
|
|
172
|
+
'[daemon] snapshot -> 2 session(s): a:idle, b:busy',
|
|
173
|
+
'',
|
|
174
|
+
].join('\n'), { mode: 0o644 });
|
|
175
|
+
new Function('fs', 'path', 'LOG_FILE', 'CONF_DIR',
|
|
176
|
+
`${slice('/// Versions up to 0.1.11 printed the pairing code', "if (subcommand === 'qr') process.exit(0);")}
|
|
177
|
+
scrubLogSecrets();`)(fs, path, LOG_FILE, dir);
|
|
178
|
+
const after = fs.readFileSync(LOG_FILE, 'utf8');
|
|
179
|
+
check('the pairing code is gone from the log', !after.includes('eyJ2IjoxLCJrIjoic2VjcmV0In0'));
|
|
180
|
+
check('so is the QR that encodes it', !after.includes(qrRow) && !after.includes('▄▄▄▄▄▄▄▄'));
|
|
181
|
+
check('everything else in the log is kept', after.includes('[daemon] connected to relay') && after.includes('b:busy'));
|
|
182
|
+
check('the log is readable by its owner only', (fs.statSync(LOG_FILE).mode & 0o777) === 0o600,
|
|
183
|
+
(fs.statSync(LOG_FILE).mode & 0o777).toString(8));
|
|
184
|
+
let writes = 0;
|
|
185
|
+
const spyFs = { ...fs, writeFileSync: (...a) => { writes++; return fs.writeFileSync(...a); } };
|
|
186
|
+
new Function('fs', 'path', 'LOG_FILE', 'CONF_DIR',
|
|
187
|
+
`${slice('/// Versions up to 0.1.11 printed the pairing code', "if (subcommand === 'qr') process.exit(0);")}
|
|
188
|
+
scrubLogSecrets();`)(spyFs, path, LOG_FILE, dir);
|
|
189
|
+
check('a log already scrubbed is not rewritten on the next start', writes === 0, `${writes} write(s)`);
|
|
190
|
+
const print = slice("if (subcommand === 'qr' || process.stdout.isTTY) {", "if (runsFromServiceDir) scrubLogSecrets();");
|
|
191
|
+
check('a daemon writing to a file prints no pairing code', /else \{\s*console\.log\(' Pairing code hidden here/.test(print));
|
|
192
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------- watch leases ----------
|
|
196
|
+
{
|
|
197
|
+
const opened = [];
|
|
198
|
+
const fakeFs = {
|
|
199
|
+
watch(file, cb) {
|
|
200
|
+
const w = { file, cb, closed: false, close() { this.closed = true; } };
|
|
201
|
+
opened.push(w);
|
|
202
|
+
return w;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const sent = [];
|
|
206
|
+
const api = new Function('fs', 'findTranscript', 'readMessages', 'toPhone',
|
|
207
|
+
`${slice('const WATCH_LEASE_MS', 'async function handleCommand(cmd) {')}
|
|
208
|
+
return { watchTranscript, unwatchTranscript, renewWatch, expireWatches, holderOf, watchers, WATCH_LEASE_MS, LEGACY_LEASE_MS };`,
|
|
209
|
+
)(fakeFs, (id) => `/tmp/${id}.jsonl`, () => [], (m) => sent.push(m));
|
|
210
|
+
const { watchTranscript, unwatchTranscript, renewWatch, expireWatches, holderOf, watchers, WATCH_LEASE_MS, LEGACY_LEASE_MS } = api;
|
|
211
|
+
const a = holderOf({ client: 'phone-a' });
|
|
212
|
+
const b = holderOf({ client: 'phone-b' });
|
|
213
|
+
|
|
214
|
+
check('the first watch starts one', watchTranscript('s1', a) === 'created' && opened.length === 1);
|
|
215
|
+
check('a second phone shares it', watchTranscript('s1', b) === 'renewed' && opened.length === 1);
|
|
216
|
+
unwatchTranscript('s1', a);
|
|
217
|
+
check('one phone closing the chat keeps it streaming for the other', watchers.has('s1') && !opened[0].closed);
|
|
218
|
+
unwatchTranscript('s1', b);
|
|
219
|
+
check('the last one closing stops it', !watchers.has('s1') && opened[0].closed);
|
|
220
|
+
|
|
221
|
+
watchTranscript('s2', a);
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
expireWatches(now + WATCH_LEASE_MS - 1000);
|
|
224
|
+
check('a lease inside its window stays', watchers.has('s2'));
|
|
225
|
+
expireWatches(now + WATCH_LEASE_MS + 1000);
|
|
226
|
+
check('a phone that vanished without an unwatch is let go', !watchers.has('s2') && opened[1].closed);
|
|
227
|
+
|
|
228
|
+
watchTranscript('s3');
|
|
229
|
+
expireWatches(now + WATCH_LEASE_MS + 1000);
|
|
230
|
+
check('an older app, which never renews, keeps its watch well past that', watchers.has('s3'));
|
|
231
|
+
renewWatch('s3', 'legacy', now + LEGACY_LEASE_MS - 5000);
|
|
232
|
+
expireWatches(now + LEGACY_LEASE_MS + 1000);
|
|
233
|
+
check('asking for the log renews it', watchers.has('s3'));
|
|
234
|
+
expireWatches(now + 2 * LEGACY_LEASE_MS);
|
|
235
|
+
check('and it still ends', !watchers.has('s3'));
|
|
236
|
+
|
|
237
|
+
renewWatch('s4', a);
|
|
238
|
+
check('a renewal never starts a watch by itself', !watchers.has('s4'));
|
|
239
|
+
|
|
240
|
+
// An older app with the chat still open lets its lease lapse while the
|
|
241
|
+
// session sits idle, then asks for the log when the session moves again.
|
|
242
|
+
watchTranscript('s5', 'legacy', now);
|
|
243
|
+
expireWatches(now + LEGACY_LEASE_MS + 1000);
|
|
244
|
+
check('an idle legacy chat lapses', !watchers.has('s5'));
|
|
245
|
+
renewWatch('s5', 'legacy', now + LEGACY_LEASE_MS + 60_000);
|
|
246
|
+
check('and asking for the log after that resumes the stream', watchers.has('s5'));
|
|
247
|
+
unwatchTranscript('s5', 'legacy');
|
|
248
|
+
watchTranscript('s6', 'legacy', now);
|
|
249
|
+
unwatchTranscript('s6', 'legacy');
|
|
250
|
+
renewWatch('s6', 'legacy', now + 1000);
|
|
251
|
+
check('a chat that was closed is not reopened by a later fetch', !watchers.has('s6'));
|
|
252
|
+
|
|
253
|
+
// A watcher that errors (journal replaced, volume hiccup) must not leave
|
|
254
|
+
// the chat silent for good: the next renewal starts a fresh one.
|
|
255
|
+
watchTranscript('s7', a, now);
|
|
256
|
+
const first = opened[opened.length - 1];
|
|
257
|
+
watchers.get('s7').watcher = null; // what an 'error' leaves behind
|
|
258
|
+
check('a renewal after a watcher error watches again', watchTranscript('s7', a, now) === 'created'
|
|
259
|
+
&& opened[opened.length - 1] !== first && watchers.has('s7'));
|
|
260
|
+
watchers.get('s7').watcher = null;
|
|
261
|
+
renewWatch('s7', a, now);
|
|
262
|
+
check('so does asking for the log', !!watchers.get('s7').watcher);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------- a turn that never stops writing still streams ----------
|
|
266
|
+
{
|
|
267
|
+
const opened = [];
|
|
268
|
+
const fakeFs = { watch(file, cb) { const w = { cb, close() {} }; opened.push(w); return w; } };
|
|
269
|
+
const sent = [];
|
|
270
|
+
const api = new Function('fs', 'findTranscript', 'readMessages', 'toPhone',
|
|
271
|
+
`${slice('const WATCH_LEASE_MS', 'async function handleCommand(cmd) {')}
|
|
272
|
+
return { watchTranscript };`,
|
|
273
|
+
)(fakeFs, (id) => `/tmp/${id}.jsonl`, () => [], (m) => sent.push(m));
|
|
274
|
+
api.watchTranscript('busy', 'phone:x');
|
|
275
|
+
const realNow = Date.now;
|
|
276
|
+
let t = realNow();
|
|
277
|
+
Date.now = () => t;
|
|
278
|
+
try {
|
|
279
|
+
for (let i = 0; i < 30; i++) { t += 100; opened[0].cb(); } // a write every 100 ms for 3 s
|
|
280
|
+
} finally { Date.now = realNow; }
|
|
281
|
+
check('continuous writes reach the phone while they last, not only after', sent.length >= 2, `${sent.length} push(es) in 3 s`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------- the approvals socket comes back after a local takeover ----------
|
|
285
|
+
{
|
|
286
|
+
const run = (inoOnDisk) => {
|
|
287
|
+
const log = { restarted: 0, closed: 0 };
|
|
288
|
+
const api = new Function('fs', 'ASK_SOCKET', 'bustHostCache', 'startApprovalServer', 'console', 'log',
|
|
289
|
+
`let approvalServer = { close() { log.closed++; } };
|
|
290
|
+
let approvalSocketIno = 41;
|
|
291
|
+
let approvalServerUp = true;
|
|
292
|
+
${slice('/// A second copy started on this Mac unlinks the approvals socket', "// Claude Code's own status for each session")}
|
|
293
|
+
return () => { ensureApprovalSocket(); return approvalServerUp; };`,
|
|
294
|
+
)({ statSync: () => { if (inoOnDisk === null) throw new Error('ENOENT'); return { ino: inoOnDisk }; } },
|
|
295
|
+
'/tmp/approvals.sock', () => {}, () => { log.restarted++; }, { log() {} }, log);
|
|
296
|
+
const upAfter = api();
|
|
297
|
+
return { ...log, upAfter };
|
|
298
|
+
};
|
|
299
|
+
const same = run(41);
|
|
300
|
+
check('our own socket is left alone', same.restarted === 0 && same.closed === 0 && same.upAfter);
|
|
301
|
+
const replaced = run(99);
|
|
302
|
+
check('a socket another copy bound is taken back', replaced.restarted === 1 && replaced.closed === 1);
|
|
303
|
+
const gone = run(null);
|
|
304
|
+
check('so is a socket file that disappeared', gone.restarted === 1);
|
|
305
|
+
const onOpen = slice(" ws.on('open', () => {", " ws.on('pong'");
|
|
306
|
+
check('and it is checked every time the relay is ours again', onOpen.includes('ensureApprovalSocket()'));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------- prompts are acknowledged when they start ----------
|
|
310
|
+
{
|
|
311
|
+
const sent = [];
|
|
312
|
+
const { promptAccepted, failureText } = new Function('toPhone',
|
|
313
|
+
`${slice('/// Tell the phone a headless run has started.', '// ---------- launching a new pilot ----------')}
|
|
314
|
+
return { promptAccepted, failureText };`)((m) => sent.push(m));
|
|
315
|
+
promptAccepted('abc');
|
|
316
|
+
check('a started run tells the phone ok at once', sent[0]?.type === 'promptResult' && sent[0].ok === true && sent[0].sessionId === 'abc');
|
|
317
|
+
check('a failure says why', failureText('codex', 1, 'warn: x\nerror: not logged in\n') === 'codex stopped: warn: x error: not logged in');
|
|
318
|
+
check('and falls back to the exit code', failureText('kimi', 2, '') === 'kimi exited with code 2');
|
|
319
|
+
for (const provider of ['claude --resume', 'spec.bin']) {
|
|
320
|
+
const at = src.indexOf(provider === 'spec.bin' ? 'const child = spawn(spec.bin' : "const child = spawn(bin, ['--resume'");
|
|
321
|
+
const block = src.slice(at, at + 1600);
|
|
322
|
+
check(`the ${provider === 'spec.bin' ? 'codex and kimi' : 'claude'} path acks on spawn`, /child\.on\('spawn', \(\) => promptAccepted\(/.test(block));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------- turning approvals off lets go of what is held ----------
|
|
327
|
+
{
|
|
328
|
+
const released = [];
|
|
329
|
+
const releaseAsksIfBack = new Function('pendingAsks', 'approvalConfig', 'releaseAllAsks', 'macIdleSeconds', 'settleAsk',
|
|
330
|
+
`${slice('function releaseAsksIfBack() {', '// ---------- self update ----------')} return releaseAsksIfBack;`,
|
|
331
|
+
)(new Map([['a1', {}]]), () => ({ mode: 'off' }), (note) => released.push(note), () => 0, () => {});
|
|
332
|
+
releaseAsksIfBack();
|
|
333
|
+
check('approvals off from the CLI hands held requests back', released[0] === 'remote approvals were turned off');
|
|
334
|
+
const phoneOff = slice('if (cmd.enabled === false) {', '} else {');
|
|
335
|
+
check('approvals off from the phone does too', phoneOff.includes("releaseAllAsks('remote approvals were turned off')"));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
|
|
339
|
+
process.exit(failures ? 1 : 0);
|