@clastres/groundcontrol 0.1.11 → 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 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.11';
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
- qrcode.generate(pairString, { small: true });
562
- console.log(`\n Pairing code (paste into the app):\n\n ${pairString}\n`);
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
- execSync(`tmux send-keys -t '${apps.tmuxPanes.get(dev)}' -l ${JSON.stringify(clean)}`);
825
+ execFileSync('tmux', ['send-keys', '-t', pane, '-l', '--', clean], { stdio: 'ignore' });
789
826
  await sleep(150);
790
- execSync(`tmux send-keys -t '${apps.tmuxPanes.get(dev)}' Enter`);
827
+ execFileSync('tmux', ['send-keys', '-t', pane, 'Enter'], { stdio: 'ignore' });
791
828
  return 'tmux';
792
829
  } catch {}
793
830
  }
@@ -1569,6 +1606,8 @@ function hostState() {
1569
1606
  const bustHostCache = () => { hostCache.at = 0; };
1570
1607
 
1571
1608
  let approvalServerUp = false;
1609
+ let approvalServer = null;
1610
+ let approvalSocketIno = 0; // which file at ASK_SOCKET is ours
1572
1611
  let availableUpdate = null; // version string once a newer release is seen
1573
1612
 
1574
1613
  const pendingAsks = new Map(); // askId -> ask record
@@ -1768,12 +1807,33 @@ function startApprovalServer(attempt = 0) {
1768
1807
  });
1769
1808
  server.listen(ASK_SOCKET, () => {
1770
1809
  try { fs.chmodSync(ASK_SOCKET, 0o600); } catch {}
1810
+ try { approvalSocketIno = fs.statSync(ASK_SOCKET).ino; } catch {}
1811
+ approvalServer = server;
1771
1812
  approvalServerUp = true;
1772
1813
  bustHostCache();
1773
1814
  console.log(`[daemon] remote approvals ready (mode: ${approvalConfig().mode})`);
1774
1815
  });
1775
1816
  }
1776
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
+
1777
1837
  // Claude Code's own status for each session, as last seen on disk.
1778
1838
  const lastRawStatus = new Map(); // sessionId -> 'busy' | 'idle' | 'waiting' | ...
1779
1839
 
@@ -1828,6 +1888,9 @@ function releaseAnsweredOnMac() {
1828
1888
  function releaseAsksIfBack() {
1829
1889
  if (!pendingAsks.size) return;
1830
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');
1831
1894
  if (cfg.mode !== 'away') return;
1832
1895
  if (macIdleSeconds() >= cfg.awayAfterSeconds) return;
1833
1896
  for (const ask of [...pendingAsks.values()]) {
@@ -1857,8 +1920,11 @@ function isNewer(candidate, current) {
1857
1920
  }
1858
1921
 
1859
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.
1860
1926
  const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
1861
- headers: { accept: 'application/vnd.npm.install-v1+json' },
1927
+ headers: { accept: 'application/json' },
1862
1928
  signal: AbortSignal.timeout(15_000),
1863
1929
  });
1864
1930
  if (!res.ok) throw new Error(`registry ${res.status}`);
@@ -1906,9 +1972,9 @@ async function checkForUpdate({ force = false } = {}) {
1906
1972
 
1907
1973
  /// A daemon shutting down must hand every held request back rather than leave
1908
1974
  /// sessions blocked on a socket that will never answer.
1909
- function releaseAllAsks() {
1975
+ function releaseAllAsks(note = 'the daemon stopped') {
1910
1976
  for (const ask of [...pendingAsks.values()]) {
1911
- settleAsk(ask, null, 'released', 'the daemon stopped');
1977
+ settleAsk(ask, null, 'released', note);
1912
1978
  }
1913
1979
  }
1914
1980
 
@@ -1917,6 +1983,17 @@ function releaseAllAsks() {
1917
1983
  let ws = null;
1918
1984
  let lastSnapshotJson = '';
1919
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
+
1920
1997
  function sendSnapshot(force = false) {
1921
1998
  // A parser tripping on a malformed journal must never take the daemon down:
1922
1999
  // this runs on a bare interval with nothing above it to catch a throw.
@@ -1940,37 +2017,102 @@ function sendSnapshot(force = false) {
1940
2017
 
1941
2018
  // Live transcript streaming: while a phone has a chat open, push transcript
1942
2019
  // updates the moment the JSONL file changes.
1943
- const watchers = new Map(); // sessionId -> fs.FSWatcher
1944
- function watchTranscript(sessionId) {
1945
- if (watchers.has(sessionId)) return;
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
+ }
1946
2049
  const file = findTranscript(sessionId);
1947
- if (!file) return;
1948
- let debounce;
2050
+ if (!file) return null;
2051
+ const next = { watcher: null, debounce: null, leases: new Map([[holder, until]]) };
1949
2052
  try {
1950
2053
  // Journals are appended to, so watching the file is enough.
1951
- const w = fs.watch(file, () => {
1952
- clearTimeout(debounce);
1953
- debounce = setTimeout(() => {
1954
- ws?.send(seal({ type: 'transcript', sessionId, messages: readMessages(sessionId) }));
2054
+ next.watcher = fs.watch(file, () => {
2055
+ clearTimeout(next.debounce);
2056
+ next.debounce = setTimeout(() => {
2057
+ toPhone({ type: 'transcript', sessionId, messages: readMessages(sessionId) });
1955
2058
  }, 250);
1956
2059
  });
1957
- watchers.set(sessionId, w);
2060
+ watchers.set(sessionId, next);
1958
2061
  console.log(`[daemon] watching transcript ${sessionId.slice(0, 8)}`);
1959
- } catch {}
2062
+ return 'created';
2063
+ } catch { return null; }
1960
2064
  }
1961
- function unwatchTranscript(sessionId) {
1962
- watchers.get(sessionId)?.close();
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);
2083
+ }
2084
+
2085
+ function closeWatch(sessionId, entry) {
2086
+ clearTimeout(entry.debounce);
2087
+ try { entry.watcher?.close(); } catch {}
1963
2088
  watchers.delete(sessionId);
1964
2089
  }
1965
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
+
1966
2103
  async function handleCommand(cmd) {
1967
2104
  if (cmd.type === 'getTranscript' && cmd.sessionId) {
1968
- ws?.send(seal({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) }));
2105
+ renewWatch(cmd.sessionId, holderOf(cmd));
2106
+ toPhone({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) });
1969
2107
  } else if (cmd.type === 'watch' && cmd.sessionId) {
1970
- watchTranscript(cmd.sessionId);
1971
- ws?.send(seal({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) }));
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
+ }
1972
2114
  } else if (cmd.type === 'unwatch' && cmd.sessionId) {
1973
- unwatchTranscript(cmd.sessionId);
2115
+ unwatchTranscript(cmd.sessionId, holderOf(cmd));
1974
2116
  } else if (cmd.type === 'setApprovals') {
1975
2117
  // A deliberate tap in the app, with the app explaining what it adds, is
1976
2118
  // the consent for this. A backup is still written before any change.
@@ -1978,6 +2120,8 @@ async function handleCommand(cmd) {
1978
2120
  if (cmd.enabled === false) {
1979
2121
  writeApprovalConfig({ mode: 'off' });
1980
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');
1981
2125
  console.log('[daemon] remote approvals turned off from the phone');
1982
2126
  } else {
1983
2127
  const mode = ['away', 'always'].includes(cmd.mode) ? cmd.mode : 'away';
@@ -1987,21 +2131,21 @@ async function handleCommand(cmd) {
1987
2131
  }
1988
2132
  bustHostCache();
1989
2133
  sendSnapshot(true);
1990
- ws?.send(seal({
2134
+ toPhone({
1991
2135
  type: 'approvalsResult',
1992
2136
  ok: !!result.ok,
1993
2137
  error: result.ok ? '' : (result.reason || 'could not change the setting'),
1994
2138
  host: hostState(),
1995
- }));
2139
+ });
1996
2140
  } else if (cmd.type === 'approve' || cmd.type === 'deny') {
1997
2141
  const ask = cmd.askId ? pendingAsks.get(cmd.askId) : null;
1998
2142
  if (!ask) {
1999
2143
  // Answered on the Mac, expired, or the session moved on while the tap
2000
2144
  // was in flight. Tell the phone so the card clears instead of hanging.
2001
- return ws?.send(seal({
2145
+ return toPhone({
2002
2146
  type: 'askResolved', askId: cmd.askId || '', sessionId: cmd.sessionId || '',
2003
2147
  outcome: 'gone', note: 'that request is no longer waiting',
2004
- }));
2148
+ });
2005
2149
  }
2006
2150
  if (cmd.type === 'approve') {
2007
2151
  settleAsk(ask, { behavior: 'allow', ...(cmd.always ? { alwaysAllow: true } : {}) },
@@ -2012,19 +2156,19 @@ async function handleCommand(cmd) {
2012
2156
  'denied', reason ? `from the phone: ${reason.slice(0, 60)}` : 'from the phone');
2013
2157
  }
2014
2158
  } else if (cmd.type === 'projects') {
2015
- ws?.send(seal({
2159
+ toPhone({
2016
2160
  type: 'projects',
2017
2161
  projects: knownProjects().map((p) => ({ path: p.path, name: p.name, at: p.at })),
2018
2162
  providers: launchProviders(),
2019
- }));
2163
+ });
2020
2164
  } else if (cmd.type === 'launch') {
2021
2165
  await launchSession(cmd);
2022
2166
  } else if (cmd.type === 'ping' && cmd.sessionId) {
2023
2167
  const s = readSessions().find((x) => x.id === cmd.sessionId);
2024
- ws?.send(seal({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() }));
2168
+ toPhone({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() });
2025
2169
  } else if (cmd.type === 'prompt' && cmd.sessionId && cmd.text) {
2026
2170
  const session = readSessions().find((s) => s.id === cmd.sessionId);
2027
- if (!session) return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' }));
2171
+ if (!session) return toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' });
2028
2172
 
2029
2173
  if (session.provider && session.provider !== 'claude') return promptHeadless(session, cmd.text);
2030
2174
 
@@ -2033,27 +2177,27 @@ async function handleCommand(cmd) {
2033
2177
  const method = await injectPrompt(session.tty, cmd.text);
2034
2178
  if (method) {
2035
2179
  console.log(`[daemon] injected prompt into ${session.name} via ${method}`);
2036
- ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: true, text: '' }));
2037
- watchTranscript(cmd.sessionId);
2180
+ toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: true, text: '' });
2181
+ watchTranscript(cmd.sessionId, 'prompt');
2038
2182
  sendSnapshot(true);
2039
2183
  return;
2040
2184
  }
2041
2185
  if (automationDenied) {
2042
2186
  console.log('[daemon] prompt blocked: macOS Automation permission missing');
2043
- return ws?.send(seal({
2187
+ return toPhone({
2044
2188
  type: 'promptResult', sessionId: cmd.sessionId, ok: false,
2045
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.',
2046
- }));
2190
+ });
2047
2191
  }
2048
2192
  }
2049
- if (session.status === 'busy') return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session is busy, wait for it to go idle' }));
2193
+ if (session.status === 'busy') return toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session is busy, wait for it to go idle' });
2050
2194
  const bin = claudeBin();
2051
2195
  if (!bin) {
2052
2196
  console.log('[daemon] prompt failed: claude binary not found on PATH');
2053
- return ws?.send(seal({
2197
+ return toPhone({
2054
2198
  type: 'promptResult', sessionId: cmd.sessionId, ok: false,
2055
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',
2056
- }));
2200
+ });
2057
2201
  }
2058
2202
  console.log(`[daemon] prompt -> ${session.name}: ${cmd.text.slice(0, 60)}`);
2059
2203
  headlessActive.add(cmd.sessionId);
@@ -2069,11 +2213,13 @@ async function handleCommand(cmd) {
2069
2213
  if (responded) return;
2070
2214
  responded = true;
2071
2215
  headlessActive.delete(cmd.sessionId);
2072
- ws?.send(seal(payload));
2216
+ toPhone(payload);
2073
2217
  sendSnapshot(true);
2074
2218
  };
2075
2219
  child.stdout.on('data', (d) => { out += d; });
2076
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));
2077
2223
  child.on('error', (e) => {
2078
2224
  console.log(`[daemon] claude spawn failed: ${e.message}`);
2079
2225
  respond({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: `could not run claude: ${e.message}` });
@@ -2082,6 +2228,7 @@ async function handleCommand(cmd) {
2082
2228
  respond({
2083
2229
  type: 'promptResult', sessionId: cmd.sessionId, ok: code === 0,
2084
2230
  text: (out || err).trim().slice(0, 4000),
2231
+ ...(code === 0 ? {} : { error: failureText('claude', code, err || out) }),
2085
2232
  });
2086
2233
  });
2087
2234
  }
@@ -2110,15 +2257,15 @@ function bustProviderCaches() {
2110
2257
  function promptHeadless(session, text) {
2111
2258
  // Anything but idle means another writer owns the session right now: busy
2112
2259
  // is a running turn, waiting is a human approval the phone cannot answer.
2113
- if (session.status === 'busy') return ws?.send(seal({ type: 'promptResult', sessionId: session.id, ok: false, error: 'session is busy, wait for it to go idle' }));
2114
- if (session.status !== 'idle') return ws?.send(seal({ type: 'promptResult', sessionId: session.id, ok: false, error: 'session is waiting for an approval, answer it in the app or terminal first' }));
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' });
2115
2262
  const spec = headlessPromptSpec[session.provider]?.();
2116
2263
  if (!spec) {
2117
2264
  console.log(`[daemon] ${session.provider} prompt failed: binary not found`);
2118
- return ws?.send(seal({
2265
+ return toPhone({
2119
2266
  type: 'promptResult', sessionId: session.id, ok: false,
2120
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.`,
2121
- }));
2268
+ });
2122
2269
  }
2123
2270
  console.log(`[daemon] prompt -> ${session.provider} ${session.name}: ${text.slice(0, 60)}`);
2124
2271
  const promptedAt = Date.now();
@@ -2137,11 +2284,12 @@ function promptHeadless(session, text) {
2137
2284
  responded = true;
2138
2285
  headlessActive.delete(session.id);
2139
2286
  bustProviderCaches();
2140
- ws?.send(seal(payload));
2287
+ toPhone(payload);
2141
2288
  sendSnapshot(true);
2142
2289
  };
2143
2290
  child.stderr.on('data', (d) => { err += d; });
2144
2291
  child.stdout.on('data', () => {});
2292
+ child.on('spawn', () => promptAccepted(session.id));
2145
2293
  child.on('error', (e) => {
2146
2294
  console.log(`[daemon] ${session.provider} spawn failed: ${e.message}`);
2147
2295
  respond({ type: 'promptResult', sessionId: session.id, ok: false, error: `could not run ${session.provider}: ${e.message}` });
@@ -2156,9 +2304,26 @@ function promptHeadless(session, text) {
2156
2304
  respond({
2157
2305
  type: 'promptResult', sessionId: session.id, ok: code === 0,
2158
2306
  text: code === 0 ? (reply?.text || '').slice(0, 4000) : err.trim().slice(0, 4000),
2307
+ ...(code === 0 ? {} : { error: failureText(session.provider, code, err) }),
2159
2308
  });
2160
2309
  });
2161
- 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}`;
2162
2327
  }
2163
2328
 
2164
2329
  // ---------- launching a new pilot ----------
@@ -2259,7 +2424,7 @@ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
2259
2424
  let lastLaunchAt = 0;
2260
2425
 
2261
2426
  async function launchSession(cmd) {
2262
- const fail = (error) => ws?.send(seal({ type: 'launchResult', ok: false, error }));
2427
+ const fail = (error) => toPhone({ type: 'launchResult', ok: false, error });
2263
2428
 
2264
2429
  const providers = launchProviders();
2265
2430
  const provider = cmd.provider || 'claude';
@@ -2317,18 +2482,60 @@ end tell`);
2317
2482
  // The new session reaches the island through the same discovery every other
2318
2483
  // one does, which takes a few seconds. Say so rather than implying the pilot
2319
2484
  // is already there.
2320
- ws?.send(seal({ type: 'launchResult', ok: true, cwd: target.path, note: `${provider} is starting in ${target.name}` }));
2485
+ toPhone({ type: 'launchResult', ok: true, cwd: target.path, note: `${provider} is starting in ${target.name}` });
2321
2486
  sendSnapshot(true);
2322
2487
  }
2323
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
+
2324
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
+ }
2325
2526
 
2326
2527
  function connect() {
2327
2528
  // handshakeTimeout: a dial that never completes would otherwise sit in
2328
2529
  // CONNECTING forever without firing close, freezing the retry loop.
2329
2530
  ws = new WebSocket(`${RELAY}/ch/${CHANNEL}?role=host`, { handshakeTimeout: 15_000 });
2330
2531
  lastPongAt = Date.now();
2331
- ws.on('open', () => { lastPongAt = Date.now(); console.log('[daemon] connected to relay'); sendSnapshot(true); });
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
+ });
2332
2539
  ws.on('pong', () => { lastPongAt = Date.now(); });
2333
2540
  ws.on('message', (data) => {
2334
2541
  const str = data.toString();
@@ -2343,14 +2550,30 @@ function connect() {
2343
2550
  // which is what the approval policy checks before holding a prompt.
2344
2551
  try {
2345
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
+ }
2346
2559
  notePhoneSeen();
2347
2560
  handleCommand(cmd)?.catch?.(() => {});
2348
2561
  } catch (e) { console.log('[daemon] bad message (wrong key?)'); }
2349
2562
  });
2350
2563
  ws.on('close', (code) => {
2351
2564
  if (code === 4001) {
2352
- console.log('[daemon] another daemon took over this channel, exiting. Run only one copy.');
2353
- process.exit(0);
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;
2354
2577
  }
2355
2578
  console.log('[daemon] relay disconnected, retrying in 3s');
2356
2579
  setTimeout(connect, 3000);
@@ -2374,6 +2597,7 @@ for (const cmd of hookState.foreign || []) console.log(`[daemon] another app als
2374
2597
  startApprovalServer();
2375
2598
  setInterval(releaseAsksIfBack, 3000);
2376
2599
  setInterval(releaseAnsweredOnMac, 2000);
2600
+ setInterval(expireWatches, 30_000);
2377
2601
 
2378
2602
  // Self update. Staggered off startup so a login storm does not hit the
2379
2603
  // registry at once, then re-checked hourly (the daily stamp does the gating).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clastres/groundcontrol",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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,294 @@
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
+ check('a prompt starting with a dash is not read as a tmux option',
61
+ dashed[0]?.indexOf('--') === dashed[0]?.length - 2, JSON.stringify(dashed[0]));
62
+ }
63
+
64
+ // ---------- sends while reconnecting do not throw ----------
65
+ {
66
+ const run = (ws) => new Function('ws', 'seal',
67
+ `${slice('/// Everything bound for the phone goes through here.', 'function sendSnapshot(')}
68
+ return toPhone;`)(ws, (o) => JSON.stringify(o));
69
+ const connecting = { readyState: 0, send() { throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); } };
70
+ let threw = false;
71
+ let sent;
72
+ try { sent = run(connecting)({ type: 'transcript' }); } catch { threw = true; }
73
+ check('a send while the next socket is still connecting does not throw', !threw && sent === false);
74
+ check('a send with no socket at all is dropped quietly', run(null)({ type: 'x' }) === false);
75
+ const out = [];
76
+ check('an open socket still gets the message', run({ readyState: 1, send: (d) => out.push(d) })({ type: 'x' }) === true && out.length === 1);
77
+ }
78
+
79
+ // ---------- self update asks npm a question it answers ----------
80
+ {
81
+ const requests = [];
82
+ // The registry's real behaviour: the abbreviated document only exists for
83
+ // the package root, so /latest with that accept header is a 406.
84
+ const fakeFetch = async (url, opts) => {
85
+ requests.push({ url, accept: opts?.headers?.accept });
86
+ if (/\/latest$/.test(url) && String(opts?.headers?.accept).includes('vnd.npm.install-v1')) {
87
+ return { ok: false, status: 406, json: async () => ({}) };
88
+ }
89
+ return { ok: true, status: 200, json: async () => ({ name: '@clastres/groundcontrol', version: '0.1.12' }) };
90
+ };
91
+ const latest = new Function('fetch', 'PACKAGE',
92
+ `${slice('async function latestPublishedVersion() {', '/// Re-install the service from the newly published version.')}
93
+ return latestPublishedVersion;`)(fakeFetch, '@clastres/groundcontrol');
94
+ let version = null;
95
+ let error = null;
96
+ try { version = await latest(); } catch (e) { error = e.message; }
97
+ check('the update check gets a version back', version === '0.1.12', error || '');
98
+ check('it asks the latest endpoint of the right package',
99
+ requests[0]?.url === 'https://registry.npmjs.org/@clastres/groundcontrol/latest', requests[0]?.url);
100
+ }
101
+
102
+ // ---------- a takeover backs off instead of restarting ----------
103
+ {
104
+ const backoff = new Function(`${slice('/// How long to stay off the relay', 'function connect() {')} return takeoverBackoffMs;`)();
105
+ const mins = [1, 2, 3, 4, 5, 6, 9].map((n) => backoff(n) / 60_000);
106
+ check('the first takeover waits a minute, then doubles', mins[0] === 1 && mins[1] === 2 && mins[2] === 4 && mins[3] === 8, mins.join(','));
107
+ check('and never waits more than fifteen minutes', mins.slice(4).every((m) => m === 15), mins.join(','));
108
+ const closeHandler = slice(" ws.on('close', (code) => {", " ws.on('error', () => {});");
109
+ check('the service copy no longer exits on a takeover',
110
+ /if \(!runsFromServiceDir\) \{[\s\S]*process\.exit\(0\);[\s\S]*\}[\s\S]*setTimeout\(connect, wait\)/.test(closeHandler));
111
+ }
112
+
113
+ // ---------- replayed and stale commands are refused ----------
114
+ {
115
+ const fresh = new Function(`${slice('// Replay protection.', 'let lastPongAt = Date.now();')} return freshCommand;`)();
116
+ const now = Date.now();
117
+ const frame = (tag) => `${tag.padEnd(16, 'A')}restOfTheCiphertext==`;
118
+ check('a new command is accepted', fresh(frame('n1'), { type: 'prompt', at: now }, now));
119
+ check('the same frame again is a replay', !fresh(frame('n1'), { type: 'prompt', at: now }, now + 1000));
120
+ check('a stamped command from twenty minutes ago is stale', !fresh(frame('n2'), { type: 'prompt', at: now - 20 * 60_000 }, now));
121
+ check('a clock a little ahead is fine', fresh(frame('n3'), { type: 'prompt', at: now + 60_000 }, now));
122
+ check('an older app with no stamp still gets through once', fresh(frame('n4'), { type: 'watch' }, now));
123
+ check('but not twice', !fresh(frame('n4'), { type: 'watch' }, now));
124
+
125
+ // The relay can respell a frame: base64 decoding skips whitespace and takes
126
+ // either alphabet. Every spelling must still count as the same frame.
127
+ const key = crypto.randomBytes(32);
128
+ const open = new Function('key', 'crypto', 'Buffer',
129
+ `${slice('function open(b64) {', '// ---------- environment hardening ----------')} return open;`)(key, crypto, Buffer);
130
+ const seal = (obj) => {
131
+ const nonce = crypto.randomBytes(12);
132
+ const c = crypto.createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
133
+ const ct = Buffer.concat([c.update(JSON.stringify(obj)), c.final()]);
134
+ return Buffer.concat([nonce, ct, c.getAuthTag()]).toString('base64');
135
+ };
136
+ const real = seal({ type: 'prompt', sessionId: 's', text: 'hi', at: now });
137
+ const spellings = [` ${real}`, `\n${real}`, `${real.slice(0, 8)} ${real.slice(8)}`, real.replace(/\+/g, '-').replace(/\//g, '_')];
138
+ check('a real frame is accepted', fresh(real, open(real), now));
139
+ for (const alt of spellings) {
140
+ let decoded = null;
141
+ try { decoded = open(alt); } catch {}
142
+ check(`respelled ${JSON.stringify(alt.slice(0, 3))}... still decrypts, and is still a replay`, decoded?.text === 'hi' && !fresh(alt, decoded, now + 5));
143
+ }
144
+ }
145
+
146
+ // ---------- the pairing key stays out of the log ----------
147
+ {
148
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-scrub-'));
149
+ const LOG_FILE = path.join(dir, 'daemon.log');
150
+ const qrRow = '█ ▄▄▄▄▄ █ █▄▀▀█▄█ ▀█ ▄▀█ █▀ ▄█▄▄ █▀▄█ ▄▄▄▄▄ █';
151
+ fs.writeFileSync(LOG_FILE, [
152
+ '[daemon] connected to relay',
153
+ '▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄',
154
+ qrRow,
155
+ ' Pairing code (paste into the app):',
156
+ ' groundcontrol://pair#eyJ2IjoxLCJrIjoic2VjcmV0In0',
157
+ '[daemon] snapshot -> 2 session(s): a:idle, b:busy',
158
+ '',
159
+ ].join('\n'), { mode: 0o644 });
160
+ new Function('fs', 'path', 'LOG_FILE', 'CONF_DIR',
161
+ `${slice('/// Versions up to 0.1.11 printed the pairing code', "if (subcommand === 'qr') process.exit(0);")}
162
+ scrubLogSecrets();`)(fs, path, LOG_FILE, dir);
163
+ const after = fs.readFileSync(LOG_FILE, 'utf8');
164
+ check('the pairing code is gone from the log', !after.includes('eyJ2IjoxLCJrIjoic2VjcmV0In0'));
165
+ check('so is the QR that encodes it', !after.includes(qrRow) && !after.includes('▄▄▄▄▄▄▄▄'));
166
+ check('everything else in the log is kept', after.includes('[daemon] connected to relay') && after.includes('b:busy'));
167
+ check('the log is readable by its owner only', (fs.statSync(LOG_FILE).mode & 0o777) === 0o600,
168
+ (fs.statSync(LOG_FILE).mode & 0o777).toString(8));
169
+ let writes = 0;
170
+ const spyFs = { ...fs, writeFileSync: (...a) => { writes++; return fs.writeFileSync(...a); } };
171
+ new Function('fs', 'path', 'LOG_FILE', 'CONF_DIR',
172
+ `${slice('/// Versions up to 0.1.11 printed the pairing code', "if (subcommand === 'qr') process.exit(0);")}
173
+ scrubLogSecrets();`)(spyFs, path, LOG_FILE, dir);
174
+ check('a log already scrubbed is not rewritten on the next start', writes === 0, `${writes} write(s)`);
175
+ const print = slice("if (subcommand === 'qr' || process.stdout.isTTY) {", "if (runsFromServiceDir) scrubLogSecrets();");
176
+ check('a daemon writing to a file prints no pairing code', /else \{\s*console\.log\(' Pairing code hidden here/.test(print));
177
+ fs.rmSync(dir, { recursive: true, force: true });
178
+ }
179
+
180
+ // ---------- watch leases ----------
181
+ {
182
+ const opened = [];
183
+ const fakeFs = {
184
+ watch(file, cb) {
185
+ const w = { file, cb, closed: false, close() { this.closed = true; } };
186
+ opened.push(w);
187
+ return w;
188
+ },
189
+ };
190
+ const sent = [];
191
+ const api = new Function('fs', 'findTranscript', 'readMessages', 'toPhone',
192
+ `${slice('const WATCH_LEASE_MS', 'async function handleCommand(cmd) {')}
193
+ return { watchTranscript, unwatchTranscript, renewWatch, expireWatches, holderOf, watchers, WATCH_LEASE_MS, LEGACY_LEASE_MS };`,
194
+ )(fakeFs, (id) => `/tmp/${id}.jsonl`, () => [], (m) => sent.push(m));
195
+ const { watchTranscript, unwatchTranscript, renewWatch, expireWatches, holderOf, watchers, WATCH_LEASE_MS, LEGACY_LEASE_MS } = api;
196
+ const a = holderOf({ client: 'phone-a' });
197
+ const b = holderOf({ client: 'phone-b' });
198
+
199
+ check('the first watch starts one', watchTranscript('s1', a) === 'created' && opened.length === 1);
200
+ check('a second phone shares it', watchTranscript('s1', b) === 'renewed' && opened.length === 1);
201
+ unwatchTranscript('s1', a);
202
+ check('one phone closing the chat keeps it streaming for the other', watchers.has('s1') && !opened[0].closed);
203
+ unwatchTranscript('s1', b);
204
+ check('the last one closing stops it', !watchers.has('s1') && opened[0].closed);
205
+
206
+ watchTranscript('s2', a);
207
+ const now = Date.now();
208
+ expireWatches(now + WATCH_LEASE_MS - 1000);
209
+ check('a lease inside its window stays', watchers.has('s2'));
210
+ expireWatches(now + WATCH_LEASE_MS + 1000);
211
+ check('a phone that vanished without an unwatch is let go', !watchers.has('s2') && opened[1].closed);
212
+
213
+ watchTranscript('s3');
214
+ expireWatches(now + WATCH_LEASE_MS + 1000);
215
+ check('an older app, which never renews, keeps its watch well past that', watchers.has('s3'));
216
+ renewWatch('s3', 'legacy', now + LEGACY_LEASE_MS - 5000);
217
+ expireWatches(now + LEGACY_LEASE_MS + 1000);
218
+ check('asking for the log renews it', watchers.has('s3'));
219
+ expireWatches(now + 2 * LEGACY_LEASE_MS);
220
+ check('and it still ends', !watchers.has('s3'));
221
+
222
+ renewWatch('s4', a);
223
+ check('a renewal never starts a watch by itself', !watchers.has('s4'));
224
+
225
+ // An older app with the chat still open lets its lease lapse while the
226
+ // session sits idle, then asks for the log when the session moves again.
227
+ watchTranscript('s5', 'legacy', now);
228
+ expireWatches(now + LEGACY_LEASE_MS + 1000);
229
+ check('an idle legacy chat lapses', !watchers.has('s5'));
230
+ renewWatch('s5', 'legacy', now + LEGACY_LEASE_MS + 60_000);
231
+ check('and asking for the log after that resumes the stream', watchers.has('s5'));
232
+ unwatchTranscript('s5', 'legacy');
233
+ watchTranscript('s6', 'legacy', now);
234
+ unwatchTranscript('s6', 'legacy');
235
+ renewWatch('s6', 'legacy', now + 1000);
236
+ check('a chat that was closed is not reopened by a later fetch', !watchers.has('s6'));
237
+ }
238
+
239
+ // ---------- the approvals socket comes back after a local takeover ----------
240
+ {
241
+ const run = (inoOnDisk) => {
242
+ const log = { restarted: 0, closed: 0 };
243
+ const api = new Function('fs', 'ASK_SOCKET', 'bustHostCache', 'startApprovalServer', 'console', 'log',
244
+ `let approvalServer = { close() { log.closed++; } };
245
+ let approvalSocketIno = 41;
246
+ let approvalServerUp = true;
247
+ ${slice('/// A second copy started on this Mac unlinks the approvals socket', "// Claude Code's own status for each session")}
248
+ return () => { ensureApprovalSocket(); return approvalServerUp; };`,
249
+ )({ statSync: () => { if (inoOnDisk === null) throw new Error('ENOENT'); return { ino: inoOnDisk }; } },
250
+ '/tmp/approvals.sock', () => {}, () => { log.restarted++; }, { log() {} }, log);
251
+ const upAfter = api();
252
+ return { ...log, upAfter };
253
+ };
254
+ const same = run(41);
255
+ check('our own socket is left alone', same.restarted === 0 && same.closed === 0 && same.upAfter);
256
+ const replaced = run(99);
257
+ check('a socket another copy bound is taken back', replaced.restarted === 1 && replaced.closed === 1);
258
+ const gone = run(null);
259
+ check('so is a socket file that disappeared', gone.restarted === 1);
260
+ const onOpen = slice(" ws.on('open', () => {", " ws.on('pong'");
261
+ check('and it is checked every time the relay is ours again', onOpen.includes('ensureApprovalSocket()'));
262
+ }
263
+
264
+ // ---------- prompts are acknowledged when they start ----------
265
+ {
266
+ const sent = [];
267
+ const { promptAccepted, failureText } = new Function('toPhone',
268
+ `${slice('/// Tell the phone a headless run has started.', '// ---------- launching a new pilot ----------')}
269
+ return { promptAccepted, failureText };`)((m) => sent.push(m));
270
+ promptAccepted('abc');
271
+ check('a started run tells the phone ok at once', sent[0]?.type === 'promptResult' && sent[0].ok === true && sent[0].sessionId === 'abc');
272
+ check('a failure says why', failureText('codex', 1, 'warn: x\nerror: not logged in\n') === 'codex stopped: warn: x error: not logged in');
273
+ check('and falls back to the exit code', failureText('kimi', 2, '') === 'kimi exited with code 2');
274
+ for (const provider of ['claude --resume', 'spec.bin']) {
275
+ const at = src.indexOf(provider === 'spec.bin' ? 'const child = spawn(spec.bin' : "const child = spawn(bin, ['--resume'");
276
+ const block = src.slice(at, at + 1600);
277
+ check(`the ${provider === 'spec.bin' ? 'codex and kimi' : 'claude'} path acks on spawn`, /child\.on\('spawn', \(\) => promptAccepted\(/.test(block));
278
+ }
279
+ }
280
+
281
+ // ---------- turning approvals off lets go of what is held ----------
282
+ {
283
+ const released = [];
284
+ const releaseAsksIfBack = new Function('pendingAsks', 'approvalConfig', 'releaseAllAsks', 'macIdleSeconds', 'settleAsk',
285
+ `${slice('function releaseAsksIfBack() {', '// ---------- self update ----------')} return releaseAsksIfBack;`,
286
+ )(new Map([['a1', {}]]), () => ({ mode: 'off' }), (note) => released.push(note), () => 0, () => {});
287
+ releaseAsksIfBack();
288
+ check('approvals off from the CLI hands held requests back', released[0] === 'remote approvals were turned off');
289
+ const phoneOff = slice('if (cmd.enabled === false) {', '} else {');
290
+ check('approvals off from the phone does too', phoneOff.includes("releaseAllAsks('remote approvals were turned off')"));
291
+ }
292
+
293
+ console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
294
+ process.exit(failures ? 1 : 0);