@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 +179 -20
- package/hook.mjs +3 -0
- package/package.json +1 -1
- package/test/questions.mjs +67 -0
- package/test/review-fixes.mjs +45 -0
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
2057
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
2151
|
-
|
|
2152
|
-
|
|
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.
|
|
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');
|
package/test/review-fixes.mjs
CHANGED
|
@@ -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 ----------
|