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