@clastres/groundcontrol 0.1.13 → 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 +101 -19
- package/package.json +1 -1
- 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 ----------
|
|
@@ -821,7 +821,7 @@ function canInject(tty) {
|
|
|
821
821
|
}
|
|
822
822
|
|
|
823
823
|
// Returns the method used, or null if no terminal path was found.
|
|
824
|
-
async function injectNow(tty, text) {
|
|
824
|
+
async function injectNow(tty, text, returns = 2) {
|
|
825
825
|
const dev = `/dev/${tty}`;
|
|
826
826
|
const apps = terminalApps();
|
|
827
827
|
const clean = text.replace(/\r?\n/g, ' ').trim();
|
|
@@ -854,7 +854,41 @@ async function injectNow(tty, text) {
|
|
|
854
854
|
end tell`);
|
|
855
855
|
if (r === 'ok') {
|
|
856
856
|
await sleep(150);
|
|
857
|
-
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"
|
|
858
892
|
repeat with w in windows
|
|
859
893
|
repeat with tb in tabs of w
|
|
860
894
|
repeat with s in sessions of tb
|
|
@@ -865,27 +899,28 @@ end tell`);
|
|
|
865
899
|
end repeat
|
|
866
900
|
end repeat
|
|
867
901
|
end repeat
|
|
868
|
-
end tell`);
|
|
869
|
-
return 'iterm';
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
if (apps.terminal) {
|
|
873
|
-
const r = await osascript(findTabScript('Terminal', dev, `do script "${escapeAS(clean)}" in tb`));
|
|
874
|
-
if (r === 'ok') {
|
|
875
|
-
await sleep(200);
|
|
876
|
-
await osascript(findTabScript('Terminal', dev, 'do script "" in tb'));
|
|
877
|
-
return 'terminal';
|
|
878
|
-
}
|
|
902
|
+
end tell`) === 'ok';
|
|
879
903
|
}
|
|
880
|
-
return
|
|
904
|
+
if (method === 'terminal') return await osascript(findTabScript('Terminal', dev, 'do script "" in tb')) === 'ok';
|
|
905
|
+
return false;
|
|
881
906
|
}
|
|
882
907
|
|
|
883
908
|
// One injection per terminal at a time. Each is two writes with a pause
|
|
884
909
|
// between them (the text, then Enter), so two prompts landing together would
|
|
885
|
-
// interleave into one run on prompt and a stray Enter.
|
|
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.
|
|
886
915
|
const injecting = new Map(); // tty -> tail of its queue
|
|
887
|
-
function injectPrompt(tty, text) {
|
|
888
|
-
const run = (injecting.get(tty) || Promise.resolve()).then(() =>
|
|
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
|
+
});
|
|
889
924
|
const tail = run.catch(() => null);
|
|
890
925
|
injecting.set(tty, tail);
|
|
891
926
|
tail.then(() => { if (injecting.get(tty) === tail) injecting.delete(tty); });
|
|
@@ -955,6 +990,41 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
|
|
|
955
990
|
return out.slice(-limit);
|
|
956
991
|
}
|
|
957
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
|
+
|
|
958
1028
|
function readClaudeSessions() {
|
|
959
1029
|
const sessions = [];
|
|
960
1030
|
let files = [];
|
|
@@ -2333,7 +2403,19 @@ async function handleCommand(cmd) {
|
|
|
2333
2403
|
|
|
2334
2404
|
// Preferred path: type into the real terminal so the desktop updates live.
|
|
2335
2405
|
if (session.live) {
|
|
2336
|
-
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
|
+
}
|
|
2337
2419
|
if (method) {
|
|
2338
2420
|
console.log(`[daemon] injected prompt into ${session.name} via ${method}`);
|
|
2339
2421
|
toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: true, text: '' });
|
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,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);
|