@clastres/groundcontrol 0.1.12 → 0.1.14

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
@@ -22,7 +22,7 @@ import { createRequire } from 'node:module';
22
22
 
23
23
  const execFileP = promisify(execFile);
24
24
 
25
- const VERSION = '0.1.12';
25
+ const VERSION = '0.1.14';
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() || ''); }
@@ -811,7 +821,7 @@ function canInject(tty) {
811
821
  }
812
822
 
813
823
  // Returns the method used, or null if no terminal path was found.
814
- async function injectPrompt(tty, text) {
824
+ async function injectNow(tty, text, returns = 2) {
815
825
  const dev = `/dev/${tty}`;
816
826
  const apps = terminalApps();
817
827
  const clean = text.replace(/\r?\n/g, ' ').trim();
@@ -844,7 +854,41 @@ async function injectPrompt(tty, text) {
844
854
  end tell`);
845
855
  if (r === 'ok') {
846
856
  await sleep(150);
847
- await osascript(`tell application "iTerm2"
857
+ await pressEnter(tty, 'iterm');
858
+ return 'iterm';
859
+ }
860
+ }
861
+ if (apps.terminal) {
862
+ // do script always ends the text with its own newline, so the text and a
863
+ // Return arrive as one chunk and Claude Code takes them as a paste. Past
864
+ // about 40 characters that paste also swallows the next bare Return,
865
+ // whatever the delay: measured on 2.1.295, a 72 character prompt sat in
866
+ // the input box until a second Return. A Return on an empty box does
867
+ // nothing, so short prompts that already went through are safe. A menu
868
+ // is not an empty box, so the caller asks for fewer around one.
869
+ const r = await osascript(findTabScript('Terminal', dev, `do script "${escapeAS(clean)}" in tb`));
870
+ if (r === 'ok') {
871
+ for (let i = 0; i < returns; i++) {
872
+ await sleep(200);
873
+ await pressEnter(tty, 'terminal');
874
+ }
875
+ return 'terminal';
876
+ }
877
+ }
878
+ return null;
879
+ }
880
+
881
+ /// A bare Return into the terminal that hosts tty, through the same route the
882
+ /// prompt took.
883
+ async function pressEnter(tty, method) {
884
+ const dev = `/dev/${tty}`;
885
+ if (method === 'tmux') {
886
+ const pane = terminalApps().tmuxPanes.get(dev);
887
+ if (!pane) return false;
888
+ try { execFileSync('tmux', ['send-keys', '-t', pane, 'Enter'], { stdio: 'ignore' }); return true; } catch { return false; }
889
+ }
890
+ if (method === 'iterm') {
891
+ return await osascript(`tell application "iTerm2"
848
892
  repeat with w in windows
849
893
  repeat with tb in tabs of w
850
894
  repeat with s in sessions of tb
@@ -855,19 +899,32 @@ end tell`);
855
899
  end repeat
856
900
  end repeat
857
901
  end repeat
858
- end tell`);
859
- return 'iterm';
860
- }
861
- }
862
- if (apps.terminal) {
863
- const r = await osascript(findTabScript('Terminal', dev, `do script "${escapeAS(clean)}" in tb`));
864
- if (r === 'ok') {
865
- await sleep(200);
866
- await osascript(findTabScript('Terminal', dev, 'do script "" in tb'));
867
- return 'terminal';
868
- }
902
+ end tell`) === 'ok';
869
903
  }
870
- return null;
904
+ if (method === 'terminal') return await osascript(findTabScript('Terminal', dev, 'do script "" in tb')) === 'ok';
905
+ return false;
906
+ }
907
+
908
+ // One injection per terminal at a time. Each is two writes with a pause
909
+ // between them (the text, then Enter), so two prompts landing together would
910
+ // interleave into one run on prompt and a stray Enter. `watch` is called as
911
+ // the slot opens, so it sees the session as this prompt finds it rather than
912
+ // before the one queued ahead of it. It may return how many Returns to send
913
+ // and a check, which runs inside the slot, so a follow up Return cannot land
914
+ // on the next prompt's text.
915
+ const injecting = new Map(); // tty -> tail of its queue
916
+ function injectPrompt(tty, text, watch) {
917
+ const run = (injecting.get(tty) || Promise.resolve()).then(async () => {
918
+ let plan = {};
919
+ try { plan = watch?.() || {}; } catch {}
920
+ const method = await injectNow(tty, text, plan.returns);
921
+ if (method && plan.check) { try { await plan.check(method); } catch {} }
922
+ return method;
923
+ });
924
+ const tail = run.catch(() => null);
925
+ injecting.set(tty, tail);
926
+ tail.then(() => { if (injecting.get(tty) === tail) injecting.delete(tty); });
927
+ return run;
871
928
  }
872
929
 
873
930
  const headlessActive = new Set(); // sessionIds with a headless --resume in flight
@@ -926,11 +983,48 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
926
983
  if (role !== 'user' && role !== 'assistant') continue;
927
984
  const text = extractText(e.message).trim();
928
985
  if (!text || /^(<system-reminder>|<command-name>|<local-command|<command-message|\[Request interrupted|\[SYSTEM NOTIFICATION)/.test(text)) continue;
929
- out.push({ role, text: text.slice(0, 4000), ts: e.timestamp || null });
986
+ // uuid is Claude Code's own id for the line, stable for as long as the
987
+ // journal lives. The phone keys its rows on it; text is no identity.
988
+ out.push({ role, text: text.slice(0, 4000), ts: e.timestamp || null, ...(e.uuid ? { id: e.uuid } : {}) });
930
989
  }
931
990
  return out.slice(-limit);
932
991
  }
933
992
 
993
+ // The terminal taking the keys is not Claude taking the prompt: a Return
994
+ // swallowed by a paste leaves the text in the input box, and the phone used
995
+ // to hear ok for it. An idle session writes a prompt to its journal the moment
996
+ // it is submitted, so look for it there, press Return once more if it is
997
+ // missing, and report through `result.landed` if it still is. Leaving idle
998
+ // counts too: a queued prompt or a slow prompt hook journals nothing yet, and
999
+ // a status that has not caught up with the prompt ahead must not read as a
1000
+ // lost one. Claude's own dialog and a slash command's picker take every
1001
+ // Return as a choice, so those keep the one Return they always got.
1002
+ const claudeStatus = (sessionId) => readClaudeSessions().find((s) => s.id === sessionId)?.rawStatus || null;
1003
+ const lastUserId = (sessionId) => readMessages(sessionId).findLast((m) => m.role === 'user')?.id || null;
1004
+ async function promptLanded(sessionId, before, ms = 2500) {
1005
+ for (let waited = 0; waited < ms; waited += 250) {
1006
+ await sleep(250);
1007
+ if (lastUserId(sessionId) !== before || claudeStatus(sessionId) !== 'idle') return true;
1008
+ }
1009
+ return false;
1010
+ }
1011
+ function promptWatch(session, text, result) {
1012
+ return () => {
1013
+ const status = claudeStatus(session.id);
1014
+ if (text.trim().startsWith('/') || status === 'waiting') return { returns: 1 };
1015
+ if (status !== 'idle') return {};
1016
+ const before = lastUserId(session.id);
1017
+ return {
1018
+ check: async (how) => {
1019
+ if (await promptLanded(session.id, before)) return;
1020
+ console.log(`[daemon] ${session.name} did not take the prompt, pressing Return again`);
1021
+ await pressEnter(session.tty, how);
1022
+ result.landed = await promptLanded(session.id, before);
1023
+ },
1024
+ };
1025
+ };
1026
+ }
1027
+
934
1028
  function readClaudeSessions() {
935
1029
  const sessions = [];
936
1030
  let files = [];
@@ -1506,8 +1600,63 @@ const askPayload = (ask, queued) => ({
1506
1600
  detail: ask.detail,
1507
1601
  at: ask.at,
1508
1602
  queued,
1603
+ // ALWAYS only means something when Claude Code offered a rule to save.
1604
+ // Apps before 0.1.11 ignore this and always show the button.
1605
+ canAlways: ask.suggestions.length > 0,
1606
+ ...(ask.questions ? { questions: ask.questions } : {}),
1509
1607
  });
1510
1608
 
1609
+ /// AskUserQuestion is a picker, not a yes or no. Allowing it without answers
1610
+ /// lets Claude carry on with nothing chosen, so the phone gets the questions
1611
+ /// themselves and answers them through updatedInput. Clipped, because every
1612
+ /// byte rides the relay and lands on a phone screen.
1613
+ function questionsOf(tool, input) {
1614
+ if (tool !== 'AskUserQuestion' || !Array.isArray(input?.questions)) return null;
1615
+ const qs = questionSources(input)
1616
+ .map((q) => ({
1617
+ question: q.question.slice(0, 600),
1618
+ header: typeof q.header === 'string' ? q.header.slice(0, 40) : '',
1619
+ multiSelect: q.multiSelect === true,
1620
+ // choice is the default; text and number have no options to tap.
1621
+ kind: typeof q.kind === 'string' ? q.kind : 'choice',
1622
+ options: (Array.isArray(q.options) ? q.options : [])
1623
+ .filter((o) => o && typeof o.label === 'string' && o.label)
1624
+ .slice(0, 8)
1625
+ .map((o) => ({
1626
+ label: o.label.slice(0, 200),
1627
+ description: typeof o.description === 'string' ? o.description.slice(0, 300) : '',
1628
+ })),
1629
+ }));
1630
+ return qs.length ? qs : null;
1631
+ }
1632
+
1633
+ /// The questions as Claude Code wrote them, in the order the phone sees them.
1634
+ /// Answers go back keyed by these full texts: the phone only ever sees a
1635
+ /// clipped copy, and Claude looks each answer up by the exact question.
1636
+ function questionSources(input) {
1637
+ return (Array.isArray(input?.questions) ? input.questions : [])
1638
+ .filter((q) => q && typeof q.question === 'string' && q.question)
1639
+ .slice(0, 6);
1640
+ }
1641
+
1642
+ /// The phone's picks, checked against the questions it was shown. Keys are
1643
+ /// the question text, which is how Claude Code looks them up; a multi select
1644
+ /// arrives as several labels and goes back joined, the way Claude's own
1645
+ /// picker reports it.
1646
+ function answersFor(ask, raw) {
1647
+ if (!ask.questions || !raw || typeof raw !== 'object') return null;
1648
+ const full = questionSources(ask.input);
1649
+ const out = {};
1650
+ ask.questions.forEach((q, i) => {
1651
+ const v = raw[q.question];
1652
+ const picked = (Array.isArray(v) ? v : [v])
1653
+ .filter((x) => typeof x === 'string' && x.trim())
1654
+ .map((x) => x.trim().slice(0, 2000));
1655
+ if (picked.length) out[full[i]?.question ?? q.question] = picked.join(', ');
1656
+ });
1657
+ return Object.keys(out).length ? out : null;
1658
+ }
1659
+
1511
1660
  function readSessions() {
1512
1661
  const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
1513
1662
  const known = new Set(sessions.map((s) => s.id));
@@ -1691,6 +1840,12 @@ function summarizeTool(tool, input) {
1691
1840
  return { line: clip(i.url, 300), detail: clip(i.prompt, 300) };
1692
1841
  case 'WebSearch':
1693
1842
  return { line: clip(i.query, 300), detail: '' };
1843
+ case 'AskUserQuestion': {
1844
+ // The questions travel on their own (questionsOf); the line is just the
1845
+ // first one, for older apps and the crew list.
1846
+ const qs = Array.isArray(i.questions) ? i.questions : [];
1847
+ return { line: clip(qs[0]?.question || 'Claude has a question', 300), detail: '' };
1848
+ }
1694
1849
  default: {
1695
1850
  // Unknown or MCP tools: show whichever field reads most like a target,
1696
1851
  // and fall back to the whole input.
@@ -1716,6 +1871,13 @@ function settleAsk(ask, decision, outcome, note) {
1716
1871
  ask.done = true;
1717
1872
  clearTimeout(ask.holdTimer);
1718
1873
  dropAsk(ask);
1874
+ // A hook that is already gone cannot take the answer. Saying "allowed" to
1875
+ // the phone then would be a lie: the session never got it.
1876
+ if (decision && (ask.socket.destroyed || !ask.socket.writable)) {
1877
+ outcome = 'gone';
1878
+ note = 'the session stopped waiting before your answer arrived';
1879
+ decision = null;
1880
+ }
1719
1881
  try { ask.socket.write(JSON.stringify({ decision: decision || null }) + '\n'); } catch {}
1720
1882
  try { ask.socket.end(); } catch {}
1721
1883
  console.log(`[daemon] permission ${ask.tool} ${outcome}${note ? `: ${note}` : ''}`);
@@ -1740,6 +1902,14 @@ function onPermissionRequest(req, socket) {
1740
1902
  tool: req.toolName || 'tool',
1741
1903
  line,
1742
1904
  detail,
1905
+ // Kept whole for AskUserQuestion, whose answer is the same input with the
1906
+ // answers added. Never sent to the phone as is.
1907
+ input: req.toolInput && typeof req.toolInput === 'object' ? req.toolInput : {},
1908
+ questions: questionsOf(req.toolName, req.toolInput),
1909
+ // What Claude Code would save if you picked "don't ask again" on the Mac.
1910
+ // ALWAYS on the phone sends these back as updatedPermissions; without
1911
+ // them there is no rule to save, so the button is hidden.
1912
+ suggestions: Array.isArray(req.permissionSuggestions) ? req.permissionSuggestions : [],
1743
1913
  cwd: req.cwd || '',
1744
1914
  at: Date.now(),
1745
1915
  socket,
@@ -2003,10 +2173,19 @@ function sendSnapshot(force = false) {
2003
2173
  return;
2004
2174
  }
2005
2175
  for (const s of sessions) if (s.rawStatus) lastRawStatus.set(s.id, s.rawStatus);
2176
+ // A session that left the fleet takes its status with it. Kept, an idle from
2177
+ // its last turn would read as "answered on the Mac" for the next request.
2178
+ const present = new Set(sessions.map((s) => s.id));
2179
+ for (const id of lastRawStatus.keys()) if (!present.has(id)) lastRawStatus.delete(id);
2006
2180
  const host = hostState();
2007
2181
  // Host state joins the dedupe key, otherwise flipping approvals on would not
2008
- // reach a phone whose session list happened not to change.
2009
- const json = JSON.stringify([sessions.map(({ lastMessage, ...s }) => ({ ...s, last: lastMessage?.text?.slice(0, 120) })), host]);
2182
+ // reach a phone whose session list happened not to change. The last message
2183
+ // is keyed on its time, role and length as well as its opening: two replies
2184
+ // that start alike would otherwise look like one and the second never sent.
2185
+ const json = JSON.stringify([sessions.map(({ lastMessage, ...s }) => ({
2186
+ ...s,
2187
+ last: lastMessage && [lastMessage.ts, lastMessage.role, lastMessage.text?.length, lastMessage.text?.slice(0, 120)],
2188
+ })), host]);
2010
2189
  if (!force && json === lastSnapshotJson) return;
2011
2190
  lastSnapshotJson = json;
2012
2191
  if (ws?.readyState === 1) {
@@ -2042,33 +2221,58 @@ function watchTranscript(sessionId, holder = 'legacy', now = Date.now()) {
2042
2221
  if (holder === 'legacy') lapsedLegacy.delete(sessionId);
2043
2222
  const until = now + leaseFor(holder);
2044
2223
  const entry = watchers.get(sessionId);
2045
- if (entry) {
2224
+ // A journal that was replaced or moved leaves the old watch listening to a
2225
+ // file nobody writes. Renewals are the moment to notice and start over.
2226
+ if (entry && !watchStale(sessionId, entry)) {
2046
2227
  entry.leases.set(holder, Math.max(until, entry.leases.get(holder) || 0));
2047
2228
  return 'renewed';
2048
2229
  }
2230
+ const leases = entry ? entry.leases : new Map();
2231
+ if (entry) closeWatch(sessionId, entry);
2232
+ leases.set(holder, Math.max(until, leases.get(holder) || 0));
2049
2233
  const file = findTranscript(sessionId);
2050
2234
  if (!file) return null;
2051
- const next = { watcher: null, debounce: null, leases: new Map([[holder, until]]) };
2235
+ const next = { watcher: null, debounce: null, firstAt: 0, file, ino: inodeOf(file), leases };
2236
+ const push = () => {
2237
+ clearTimeout(next.debounce);
2238
+ next.debounce = null;
2239
+ next.firstAt = 0;
2240
+ toPhone({ type: 'transcript', sessionId, messages: readMessages(sessionId) });
2241
+ };
2052
2242
  try {
2053
- // Journals are appended to, so watching the file is enough.
2243
+ // Journals are appended to, so watching the file is enough. Debounced so
2244
+ // a burst of writes is one read, but never for longer than a second: a
2245
+ // turn that keeps writing would otherwise reach the phone only once it
2246
+ // stopped, which is the chat that looks frozen while the Mac is busy.
2054
2247
  next.watcher = fs.watch(file, () => {
2248
+ const t = Date.now();
2249
+ if (!next.firstAt) next.firstAt = t;
2055
2250
  clearTimeout(next.debounce);
2056
- next.debounce = setTimeout(() => {
2057
- toPhone({ type: 'transcript', sessionId, messages: readMessages(sessionId) });
2058
- }, 250);
2251
+ if (t - next.firstAt >= TRANSCRIPT_MAX_WAIT_MS) return push();
2252
+ next.debounce = setTimeout(push, 250);
2059
2253
  });
2254
+ next.watcher.on?.('error', () => closeWatch(sessionId, next, { keepIntent: true }));
2060
2255
  watchers.set(sessionId, next);
2061
2256
  console.log(`[daemon] watching transcript ${sessionId.slice(0, 8)}`);
2062
2257
  return 'created';
2063
2258
  } catch { return null; }
2064
2259
  }
2065
2260
 
2261
+ const TRANSCRIPT_MAX_WAIT_MS = 1000;
2262
+ const inodeOf = (file) => { try { return fs.statSync(file).ino; } catch { return 0; } };
2263
+ function watchStale(sessionId, entry) {
2264
+ if (!entry.watcher) return true;
2265
+ const file = findTranscript(sessionId);
2266
+ return !!file && (file !== entry.file || inodeOf(file) !== entry.ino);
2267
+ }
2268
+
2066
2269
  /// Keep an existing lease alive without starting a watch nobody asked for,
2067
2270
  /// except the one an older app's open chat lost to the clock.
2068
2271
  function renewWatch(sessionId, holder, now = Date.now()) {
2069
2272
  const entry = watchers.get(sessionId);
2070
2273
  if (entry?.leases.has(holder)) {
2071
- entry.leases.set(holder, now + leaseFor(holder));
2274
+ if (watchStale(sessionId, entry)) watchTranscript(sessionId, holder, now);
2275
+ else entry.leases.set(holder, now + leaseFor(holder));
2072
2276
  return;
2073
2277
  }
2074
2278
  if (holder === 'legacy' && lapsedLegacy.has(sessionId)) watchTranscript(sessionId, 'legacy', now);
@@ -2082,10 +2286,14 @@ function unwatchTranscript(sessionId, holder = 'legacy') {
2082
2286
  if (!entry.leases.size) closeWatch(sessionId, entry);
2083
2287
  }
2084
2288
 
2085
- function closeWatch(sessionId, entry) {
2289
+ /// keepIntent leaves the leases in place with no watcher behind them, so the
2290
+ /// next renewal or getTranscript starts a fresh one instead of the chat going
2291
+ /// quiet for good after one watcher error.
2292
+ function closeWatch(sessionId, entry, { keepIntent = false } = {}) {
2086
2293
  clearTimeout(entry.debounce);
2087
2294
  try { entry.watcher?.close(); } catch {}
2088
- watchers.delete(sessionId);
2295
+ entry.watcher = null;
2296
+ if (!keepIntent && watchers.get(sessionId) === entry) watchers.delete(sessionId);
2089
2297
  }
2090
2298
 
2091
2299
  function expireWatches(now = Date.now()) {
@@ -2137,19 +2345,40 @@ async function handleCommand(cmd) {
2137
2345
  error: result.ok ? '' : (result.reason || 'could not change the setting'),
2138
2346
  host: hostState(),
2139
2347
  });
2140
- } else if (cmd.type === 'approve' || cmd.type === 'deny') {
2348
+ } else if (cmd.type === 'approve' || cmd.type === 'deny' || cmd.type === 'answer') {
2141
2349
  const ask = cmd.askId ? pendingAsks.get(cmd.askId) : null;
2142
2350
  if (!ask) {
2143
2351
  // Answered on the Mac, expired, or the session moved on while the tap
2144
- // was in flight. Tell the phone so the card clears instead of hanging.
2145
- return toPhone({
2352
+ // was in flight. Tell the phone so the card clears instead of hanging,
2353
+ // and send the fleet with it: the phone draws the card from the
2354
+ // snapshot, and a deduped one would never come.
2355
+ toPhone({
2146
2356
  type: 'askResolved', askId: cmd.askId || '', sessionId: cmd.sessionId || '',
2147
2357
  outcome: 'gone', note: 'that request is no longer waiting',
2148
2358
  });
2359
+ sendSnapshot(true);
2360
+ return;
2149
2361
  }
2150
- if (cmd.type === 'approve') {
2151
- settleAsk(ask, { behavior: 'allow', ...(cmd.always ? { alwaysAllow: true } : {}) },
2152
- 'allowed', cmd.always ? 'from the phone, and always from now on' : 'from the phone');
2362
+ if (cmd.type === 'answer') {
2363
+ const answers = answersFor(ask, cmd.answers);
2364
+ if (!answers) {
2365
+ // Nothing usable picked. Leave the request held; the card stays up.
2366
+ return toPhone({ type: 'askResolved', askId: ask.id, sessionId: ask.sessionId, outcome: 'invalid', note: 'pick an answer first' });
2367
+ }
2368
+ settleAsk(ask, { behavior: 'allow', updatedInput: { ...ask.input, answers } },
2369
+ 'answered', 'from the phone');
2370
+ } else if (cmd.type === 'approve' && ask.questions) {
2371
+ // An app too old to show the choices can only say yes, and yes with no
2372
+ // answer is a question skipped. Hand it back so it gets a real answer.
2373
+ settleAsk(ask, null, 'released', 'this needs a choice, update the app or pick it');
2374
+ } else if (cmd.type === 'approve') {
2375
+ // Claude Code reads only behavior, updatedInput and updatedPermissions.
2376
+ // Up to 0.1.12 ALWAYS sent alwaysAllow, which it drops, so the next
2377
+ // matching call asked again. The suggestions are the exact rules it
2378
+ // would have saved from its own "don't ask again" option.
2379
+ const always = cmd.always === true && ask.suggestions.length > 0;
2380
+ settleAsk(ask, { behavior: 'allow', ...(always ? { updatedPermissions: ask.suggestions } : {}) },
2381
+ 'allowed', always ? 'from the phone, and always from now on' : 'from the phone');
2153
2382
  } else {
2154
2383
  const reason = (cmd.text || '').trim();
2155
2384
  settleAsk(ask, { behavior: 'deny', message: reason || 'Denied from the Ground Control app.' },
@@ -2174,7 +2403,19 @@ async function handleCommand(cmd) {
2174
2403
 
2175
2404
  // Preferred path: type into the real terminal so the desktop updates live.
2176
2405
  if (session.live) {
2177
- const method = await injectPrompt(session.tty, cmd.text);
2406
+ const result = { landed: true };
2407
+ const method = await injectPrompt(session.tty, cmd.text, promptWatch(session, cmd.text, result));
2408
+ if (method && !result.landed) {
2409
+ console.log(`[daemon] prompt typed into ${session.name} via ${method} but never submitted`);
2410
+ toPhone({
2411
+ type: 'promptResult', sessionId: cmd.sessionId, ok: false,
2412
+ error: 'Typed into the terminal on your Mac, but Claude did not take it. It may be waiting in the input box there.',
2413
+ });
2414
+ // Pressed by hand on the Mac, it still reaches the phone.
2415
+ watchTranscript(cmd.sessionId, 'prompt');
2416
+ sendSnapshot(true);
2417
+ return;
2418
+ }
2178
2419
  if (method) {
2179
2420
  console.log(`[daemon] injected prompt into ${session.name} via ${method}`);
2180
2421
  toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: true, text: '' });
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.12",
3
+ "version": "0.1.14",
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');
@@ -57,6 +57,21 @@ const check = (label, cond, detail = '') => {
57
57
  () => { throw new Error('shell'); },
58
58
  async () => {}, async () => 'notfound', (s) => s,
59
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(','));
60
75
  check('a prompt starting with a dash is not read as a tmux option',
61
76
  dashed[0]?.indexOf('--') === dashed[0]?.length - 2, JSON.stringify(dashed[0]));
62
77
  }
@@ -234,6 +249,36 @@ const check = (label, cond, detail = '') => {
234
249
  unwatchTranscript('s6', 'legacy');
235
250
  renewWatch('s6', 'legacy', now + 1000);
236
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`);
237
282
  }
238
283
 
239
284
  // ---------- the approvals socket comes back after a local takeover ----------
@@ -0,0 +1,138 @@
1
+ // A prompt typed into Terminal.app has to be submitted, not left in the input
2
+ // box. On 2026-10-11 a 72 character prompt from the phone sat there unsent:
3
+ // do script sends the text with its own newline, Claude Code takes that as a
4
+ // paste, and the paste swallows the one Return that followed. Lift the real
5
+ // injection code out of daemon.mjs and run it against a fake Terminal.
6
+ import fs from 'node:fs';
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
+ const from = '// Returns the method used, or null if no terminal path was found.';
13
+ const start = src.indexOf(from);
14
+ const end = src.indexOf('const headlessActive = new Set();', start);
15
+ if (start < 0 || end < 0) throw new Error('could not locate the injection code in daemon.mjs');
16
+
17
+ let failures = 0;
18
+ const check = (label, cond, detail = '') => {
19
+ console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
20
+ if (!cond) failures++;
21
+ };
22
+
23
+ const load = (scripts) => new Function(
24
+ 'terminalApps', 'execFileSync', 'execSync', 'sleep', 'osascript', 'escapeAS', 'findTabScript',
25
+ `${src.slice(start, end)}
26
+ return { injectPrompt, pressEnter };`,
27
+ )(
28
+ () => ({ tmuxPanes: new Map(), terminal: true, iterm: false }),
29
+ () => { throw new Error('no tmux here'); },
30
+ () => { throw new Error('no shell'); },
31
+ async () => {},
32
+ async (script) => { scripts.push(script.match(/do script "(.*)" in tb/)?.[1] ?? script); return 'ok'; },
33
+ (s) => s,
34
+ (app, dev, action) => action,
35
+ );
36
+
37
+ {
38
+ const scripts = [];
39
+ const { injectPrompt } = load(scripts);
40
+ const method = await injectPrompt('ttys005', 'Do we have pstack installed? Do you need it or do you have enough tools?');
41
+ check('a Terminal.app session is typed into through Terminal', method === 'terminal', `got ${method}`);
42
+ check('the text is followed by two bare Returns, enough to get past the paste',
43
+ scripts.length === 3 && scripts[1] === '' && scripts[2] === '', JSON.stringify(scripts));
44
+ }
45
+
46
+ {
47
+ // The session is read as this prompt's slot opens, after the prompt ahead
48
+ // of it went in, and the follow up check runs inside the slot: its extra
49
+ // Return must land before the next prompt is typed, not on top of it.
50
+ const scripts = [];
51
+ const { injectPrompt, pressEnter } = load(scripts);
52
+ const watch = (label) => () => { scripts.push(`WATCH ${label}`); return { check: async (how) => { scripts.push('CHECK'); await pressEnter('ttys005', how); } }; };
53
+ await Promise.all([injectPrompt('ttys005', 'FIRST', watch('1')), injectPrompt('ttys005', 'SECOND', watch('2'))]);
54
+ check('each prompt is watched from its own slot, with the extra Return before the next prompt',
55
+ scripts.join(',') === 'WATCH 1,FIRST,,,CHECK,,WATCH 2,SECOND,,,CHECK,', scripts.join(','));
56
+ let survived = true;
57
+ try {
58
+ await injectPrompt('ttys005', 'THIRD', () => ({ check: async () => { throw new Error('boom'); } }));
59
+ await injectPrompt('ttys005', 'FOURTH', () => { throw new Error('boom'); });
60
+ } catch { survived = false; }
61
+ check('a failing check does not turn a typed prompt into an error', survived);
62
+ }
63
+
64
+ {
65
+ // Fewer Returns where a Return is a choice: Claude's own dialog, a picker.
66
+ const scripts = [];
67
+ const { injectPrompt } = load(scripts);
68
+ await injectPrompt('ttys005', '/model', () => ({ returns: 1 }));
69
+ check('a slash command or an open dialog gets the one Return it always got',
70
+ scripts.length === 2 && scripts[1] === '', JSON.stringify(scripts));
71
+ }
72
+
73
+ // ---------- the handler's watch: is the prompt really in? ----------
74
+ {
75
+ const wStart = src.indexOf('// The terminal taking the keys is not Claude taking the prompt');
76
+ const wEnd = src.indexOf('function readClaudeSessions()', wStart);
77
+ if (wStart < 0 || wEnd < 0) throw new Error('could not locate the prompt watch in daemon.mjs');
78
+ // A fake Claude: its status and journal change on cue as Returns are pressed.
79
+ const rig = ({ status = 'idle', onEnter = () => {} } = {}) => {
80
+ const st = { status, ids: ['u1'], enters: 0 };
81
+ const api = new Function(
82
+ 'readClaudeSessions', 'readMessages', 'sleep', 'pressEnter', 'console',
83
+ `${src.slice(wStart, wEnd)}
84
+ return { promptWatch };`,
85
+ )(
86
+ () => [{ id: 's1', rawStatus: st.status }],
87
+ () => st.ids.map((id) => ({ role: 'user', id })),
88
+ async () => {},
89
+ async () => { st.enters++; onEnter(st); return true; },
90
+ { log() {} },
91
+ );
92
+ return { st, watch: (text = 'Do we have pstack installed? Do you need it or do you have enough tools?') => {
93
+ const result = { landed: true };
94
+ return { result, plan: api.promptWatch({ id: 's1', name: 'test', tty: 'ttys005' }, text, result)() };
95
+ } };
96
+ };
97
+
98
+ {
99
+ const { st, watch } = rig();
100
+ const { result, plan } = watch();
101
+ await plan.check('terminal');
102
+ check('a prompt stuck in the box gets one more Return, then is reported as not taken',
103
+ st.enters === 1 && result.landed === false, `enters ${st.enters}, landed ${result.landed}`);
104
+ }
105
+ {
106
+ const { st, watch } = rig({ onEnter: (x) => x.ids.push('u2') });
107
+ const { result, plan } = watch();
108
+ await plan.check('terminal');
109
+ check('one that the extra Return submits counts as landed', st.enters === 1 && result.landed === true);
110
+ }
111
+ {
112
+ const { st, watch } = rig();
113
+ const { result, plan } = watch();
114
+ st.ids.push('u2');
115
+ await plan.check('terminal');
116
+ check('one that went straight in gets no extra Return', st.enters === 0 && result.landed === true);
117
+ }
118
+ {
119
+ // The status file lags the prompt ahead by about a tenth of a second,
120
+ // so this one was read as idle and then got queued: nothing journaled.
121
+ const { st, watch } = rig();
122
+ const { result, plan } = watch();
123
+ st.status = 'busy';
124
+ await plan.check('terminal');
125
+ check('one queued behind a turn that just started is not called lost', st.enters === 0 && result.landed === true);
126
+ }
127
+ const plans = {
128
+ busy: rig({ status: 'busy' }).watch().plan,
129
+ waiting: rig({ status: 'waiting' }).watch().plan,
130
+ slash: rig().watch('/model').plan,
131
+ };
132
+ check('a busy session is typed into as usual with nothing to check', !plans.busy.check && plans.busy.returns === undefined);
133
+ check('Claude\'s own dialog gets one Return and no check', plans.waiting.returns === 1 && !plans.waiting.check);
134
+ check('a slash command gets one Return and no check', plans.slash.returns === 1 && !plans.slash.check);
135
+ }
136
+
137
+ console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
138
+ process.exit(failures ? 1 : 0);