@clastres/groundcontrol 0.1.12 → 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 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.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() || ''); }
@@ -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) {
815
825
  const dev = `/dev/${tty}`;
816
826
  const apps = terminalApps();
817
827
  const clean = text.replace(/\r?\n/g, ' ').trim();
@@ -870,6 +880,18 @@ end tell`);
870
880
  return null;
871
881
  }
872
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
+
873
895
  const headlessActive = new Set(); // sessionIds with a headless --resume in flight
874
896
 
875
897
  const transcriptCache = new Map(); // sessionId -> file path
@@ -926,7 +948,9 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
926
948
  if (role !== 'user' && role !== 'assistant') continue;
927
949
  const text = extractText(e.message).trim();
928
950
  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 });
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 } : {}) });
930
954
  }
931
955
  return out.slice(-limit);
932
956
  }
@@ -1506,8 +1530,63 @@ const askPayload = (ask, queued) => ({
1506
1530
  detail: ask.detail,
1507
1531
  at: ask.at,
1508
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 } : {}),
1509
1537
  });
1510
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
+
1511
1590
  function readSessions() {
1512
1591
  const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
1513
1592
  const known = new Set(sessions.map((s) => s.id));
@@ -1691,6 +1770,12 @@ function summarizeTool(tool, input) {
1691
1770
  return { line: clip(i.url, 300), detail: clip(i.prompt, 300) };
1692
1771
  case 'WebSearch':
1693
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
+ }
1694
1779
  default: {
1695
1780
  // Unknown or MCP tools: show whichever field reads most like a target,
1696
1781
  // and fall back to the whole input.
@@ -1716,6 +1801,13 @@ function settleAsk(ask, decision, outcome, note) {
1716
1801
  ask.done = true;
1717
1802
  clearTimeout(ask.holdTimer);
1718
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
+ }
1719
1811
  try { ask.socket.write(JSON.stringify({ decision: decision || null }) + '\n'); } catch {}
1720
1812
  try { ask.socket.end(); } catch {}
1721
1813
  console.log(`[daemon] permission ${ask.tool} ${outcome}${note ? `: ${note}` : ''}`);
@@ -1740,6 +1832,14 @@ function onPermissionRequest(req, socket) {
1740
1832
  tool: req.toolName || 'tool',
1741
1833
  line,
1742
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 : [],
1743
1843
  cwd: req.cwd || '',
1744
1844
  at: Date.now(),
1745
1845
  socket,
@@ -2003,10 +2103,19 @@ function sendSnapshot(force = false) {
2003
2103
  return;
2004
2104
  }
2005
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);
2006
2110
  const host = hostState();
2007
2111
  // 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]);
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]);
2010
2119
  if (!force && json === lastSnapshotJson) return;
2011
2120
  lastSnapshotJson = json;
2012
2121
  if (ws?.readyState === 1) {
@@ -2042,33 +2151,58 @@ function watchTranscript(sessionId, holder = 'legacy', now = Date.now()) {
2042
2151
  if (holder === 'legacy') lapsedLegacy.delete(sessionId);
2043
2152
  const until = now + leaseFor(holder);
2044
2153
  const entry = watchers.get(sessionId);
2045
- if (entry) {
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)) {
2046
2157
  entry.leases.set(holder, Math.max(until, entry.leases.get(holder) || 0));
2047
2158
  return 'renewed';
2048
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));
2049
2163
  const file = findTranscript(sessionId);
2050
2164
  if (!file) return null;
2051
- const next = { watcher: null, debounce: null, leases: new Map([[holder, until]]) };
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
+ };
2052
2172
  try {
2053
- // Journals are appended to, so watching the file is enough.
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.
2054
2177
  next.watcher = fs.watch(file, () => {
2178
+ const t = Date.now();
2179
+ if (!next.firstAt) next.firstAt = t;
2055
2180
  clearTimeout(next.debounce);
2056
- next.debounce = setTimeout(() => {
2057
- toPhone({ type: 'transcript', sessionId, messages: readMessages(sessionId) });
2058
- }, 250);
2181
+ if (t - next.firstAt >= TRANSCRIPT_MAX_WAIT_MS) return push();
2182
+ next.debounce = setTimeout(push, 250);
2059
2183
  });
2184
+ next.watcher.on?.('error', () => closeWatch(sessionId, next, { keepIntent: true }));
2060
2185
  watchers.set(sessionId, next);
2061
2186
  console.log(`[daemon] watching transcript ${sessionId.slice(0, 8)}`);
2062
2187
  return 'created';
2063
2188
  } catch { return null; }
2064
2189
  }
2065
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
+
2066
2199
  /// Keep an existing lease alive without starting a watch nobody asked for,
2067
2200
  /// except the one an older app's open chat lost to the clock.
2068
2201
  function renewWatch(sessionId, holder, now = Date.now()) {
2069
2202
  const entry = watchers.get(sessionId);
2070
2203
  if (entry?.leases.has(holder)) {
2071
- entry.leases.set(holder, now + leaseFor(holder));
2204
+ if (watchStale(sessionId, entry)) watchTranscript(sessionId, holder, now);
2205
+ else entry.leases.set(holder, now + leaseFor(holder));
2072
2206
  return;
2073
2207
  }
2074
2208
  if (holder === 'legacy' && lapsedLegacy.has(sessionId)) watchTranscript(sessionId, 'legacy', now);
@@ -2082,10 +2216,14 @@ function unwatchTranscript(sessionId, holder = 'legacy') {
2082
2216
  if (!entry.leases.size) closeWatch(sessionId, entry);
2083
2217
  }
2084
2218
 
2085
- function closeWatch(sessionId, entry) {
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 } = {}) {
2086
2223
  clearTimeout(entry.debounce);
2087
2224
  try { entry.watcher?.close(); } catch {}
2088
- watchers.delete(sessionId);
2225
+ entry.watcher = null;
2226
+ if (!keepIntent && watchers.get(sessionId) === entry) watchers.delete(sessionId);
2089
2227
  }
2090
2228
 
2091
2229
  function expireWatches(now = Date.now()) {
@@ -2137,19 +2275,40 @@ async function handleCommand(cmd) {
2137
2275
  error: result.ok ? '' : (result.reason || 'could not change the setting'),
2138
2276
  host: hostState(),
2139
2277
  });
2140
- } else if (cmd.type === 'approve' || cmd.type === 'deny') {
2278
+ } else if (cmd.type === 'approve' || cmd.type === 'deny' || cmd.type === 'answer') {
2141
2279
  const ask = cmd.askId ? pendingAsks.get(cmd.askId) : null;
2142
2280
  if (!ask) {
2143
2281
  // 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({
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({
2146
2286
  type: 'askResolved', askId: cmd.askId || '', sessionId: cmd.sessionId || '',
2147
2287
  outcome: 'gone', note: 'that request is no longer waiting',
2148
2288
  });
2289
+ sendSnapshot(true);
2290
+ return;
2149
2291
  }
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');
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');
2153
2312
  } else {
2154
2313
  const reason = (cmd.text || '').trim();
2155
2314
  settleAsk(ask, { behavior: 'deny', message: reason || 'Denied from the Ground Control app.' },
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.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');
@@ -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 ----------