@celljs/ta-computer-use 0.0.0-stage → 4.8.0
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/README.en.md +37 -0
- package/README.md +36 -2
- package/extension/index.mjs +551 -0
- package/package.json +49 -4
- package/src/cua-driver-access.spec.ts +147 -0
- package/src/cua-driver-access.ts +188 -0
- package/src/cua-driver-daemon.spec.ts +168 -0
- package/src/cua-driver-daemon.ts +283 -0
- package/src/cua-driver.spec.ts +85 -0
- package/src/cua-driver.ts +130 -0
- package/src/extension.spec.ts +279 -0
- package/src/extension.ts +195 -0
- package/src/pi-types.ts +2 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { connect } from 'node:net';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const START_TIMEOUT_MS = 10000;
|
|
8
|
+
const POLL_MS = 20;
|
|
9
|
+
/** Unused this long, the daemon stops (about 60 MB); the next use starts it again in a few hundred ms. */
|
|
10
|
+
const IDLE_MS = 10 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
/** Starts the daemon: `app` is the CuaDriver.app, `args` those of its program. */
|
|
13
|
+
export type CuaDriverLauncher = (app: string, args: string[], env: Readonly<Record<string, string>>) => Promise<void>;
|
|
14
|
+
|
|
15
|
+
export interface CuaDriverDaemonOptions {
|
|
16
|
+
dir?: string;
|
|
17
|
+
/** Where an app inside another app's bundle is copied to run, by version. */
|
|
18
|
+
stageDir?: string;
|
|
19
|
+
/** For the daemon's environment. */
|
|
20
|
+
env?: Readonly<Record<string, string>>;
|
|
21
|
+
launch?: CuaDriverLauncher;
|
|
22
|
+
idleMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The daemon of the Cua Driver that comes with Ta Desktop, on macOS. There its MCP server only relays to a
|
|
27
|
+
* daemon, which macOS must start as CuaDriver.app, so that the permissions (Accessibility, Screen
|
|
28
|
+
* Recording) are the app's and not Ta's. Left alone, `cua-driver mcp` starts the CuaDriver.app in
|
|
29
|
+
* /Applications, which may be another version or missing; so Ta starts its own on a socket of its own and
|
|
30
|
+
* runs `cua-driver mcp --embedded --socket`, which only relays to it. Started on first use, it stops when
|
|
31
|
+
* unused for a while or when the process that started it exits; the relays exit with it, and pi starts
|
|
32
|
+
* them again on the next call.
|
|
33
|
+
*/
|
|
34
|
+
export class CuaDriverDaemon {
|
|
35
|
+
readonly socket: string;
|
|
36
|
+
readonly pidFile: string;
|
|
37
|
+
readonly dir: string;
|
|
38
|
+
/** The app that runs: `source`, or its copy outside the bundle that contains it. */
|
|
39
|
+
readonly app: string;
|
|
40
|
+
protected starting?: Promise<void>;
|
|
41
|
+
protected launched = false;
|
|
42
|
+
/** The daemon on the socket is this app's: checked once for one this process did not start. */
|
|
43
|
+
protected verified = false;
|
|
44
|
+
protected idle?: ReturnType<typeof setTimeout>;
|
|
45
|
+
protected readonly launch: CuaDriverLauncher;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
readonly source: string,
|
|
49
|
+
protected readonly options: CuaDriverDaemonOptions = {},
|
|
50
|
+
) {
|
|
51
|
+
this.dir = options.dir ?? join(homedir(), 'Library', 'Caches', 'ta');
|
|
52
|
+
this.app = CuaDriverDaemon.nested(source) ? join(this.stageDir, CuaDriverDaemon.version(source), 'CuaDriver.app') : source;
|
|
53
|
+
this.socket = join(this.dir, 'cua-driver.sock');
|
|
54
|
+
this.pidFile = join(this.dir, 'cua-driver.pid');
|
|
55
|
+
this.launch = options.launch ?? openApp;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Running, started if it is not; one left by an earlier run is used as it is. */
|
|
59
|
+
async ensure(): Promise<void> {
|
|
60
|
+
if ((await this.alive()) && (this.verified || (await this.adopt()))) {
|
|
61
|
+
this.touch();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Unused is counted from when it answers: a start slower than the idle time is not stopped.
|
|
65
|
+
clearTimeout(this.idle);
|
|
66
|
+
this.starting ??= this.start().finally(() => {
|
|
67
|
+
this.starting = undefined;
|
|
68
|
+
});
|
|
69
|
+
await this.starting;
|
|
70
|
+
this.touch();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether it answers on its socket: a connection, which costs well under a millisecond. */
|
|
74
|
+
alive(): Promise<boolean> {
|
|
75
|
+
if (!existsSync(this.socket)) {
|
|
76
|
+
return Promise.resolve(false);
|
|
77
|
+
}
|
|
78
|
+
return new Promise(resolve => {
|
|
79
|
+
const socket = connect(this.socket);
|
|
80
|
+
socket.once('connect', () => {
|
|
81
|
+
socket.destroy();
|
|
82
|
+
resolve(true);
|
|
83
|
+
});
|
|
84
|
+
socket.on('error', () => resolve(false));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Stops it if this process started it. Synchronous, for the `exit` event. */
|
|
89
|
+
stop(): void {
|
|
90
|
+
clearTimeout(this.idle);
|
|
91
|
+
if (!this.launched) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.launched = false;
|
|
95
|
+
const pid = this.pid();
|
|
96
|
+
if (pid) {
|
|
97
|
+
try {
|
|
98
|
+
process.kill(pid, 'SIGTERM');
|
|
99
|
+
} catch {
|
|
100
|
+
// Gone already.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Stops other daemons of the same app, such as the one macOS starts again without Ta's arguments when
|
|
107
|
+
* the user lets it quit and reopen for Screen Recording. Only processes of this very program.
|
|
108
|
+
*/
|
|
109
|
+
async reap(): Promise<void> {
|
|
110
|
+
const program = this.program;
|
|
111
|
+
const own = this.pid();
|
|
112
|
+
const list = await new Promise<string>(resolve => execFile('ps', ['-axo', 'pid=,command='], (_error, stdout) => resolve(String(stdout ?? ''))));
|
|
113
|
+
for (const line of list.split('\n')) {
|
|
114
|
+
const [, pid, command] = /^\s*(\d+)\s+(.*)$/.exec(line) ?? [];
|
|
115
|
+
if (pid && Number(pid) !== own && command.startsWith(`${program} serve`) && !command.includes(this.socket)) {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(Number(pid), 'SIGTERM');
|
|
118
|
+
} catch {
|
|
119
|
+
// Gone already.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether the running daemon, left by another Ta process or an earlier run, is this app's; another
|
|
127
|
+
* copy's (another version, whose relay would not match) is stopped, to be replaced.
|
|
128
|
+
*/
|
|
129
|
+
protected async adopt(): Promise<boolean> {
|
|
130
|
+
const running = await this.identify();
|
|
131
|
+
if (!running || running.executable === this.program) {
|
|
132
|
+
this.verified = true;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
process.kill(running.pid, 'SIGTERM');
|
|
137
|
+
} catch {
|
|
138
|
+
// Gone already.
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The program and process of the daemon on the socket, as it says; undefined when it does not say. */
|
|
144
|
+
protected identify(): Promise<{ executable: string; pid: number } | undefined> {
|
|
145
|
+
return new Promise(resolve =>
|
|
146
|
+
execFile(
|
|
147
|
+
this.program,
|
|
148
|
+
['call', 'check_permissions', '{"prompt":false}', '--socket', this.socket],
|
|
149
|
+
{ timeout: 5000, env: { ...process.env, ...this.options.env } },
|
|
150
|
+
(_error, stdout) => {
|
|
151
|
+
try {
|
|
152
|
+
const { source } = JSON.parse(String(stdout)) as { source?: { executable?: string; pid?: number } };
|
|
153
|
+
resolve(source?.executable && source.pid ? { executable: source.executable, pid: source.pid } : undefined);
|
|
154
|
+
} catch {
|
|
155
|
+
resolve(undefined);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected get program(): string {
|
|
163
|
+
return join(this.app, 'Contents', 'MacOS', 'cua-driver');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** That the daemon is this app's program, not another copy LaunchServices chose; else it is stopped. */
|
|
167
|
+
protected async verify(): Promise<void> {
|
|
168
|
+
const pid = this.pid();
|
|
169
|
+
const command = pid ? await this.commandOf(pid) : undefined;
|
|
170
|
+
const program = this.program;
|
|
171
|
+
if (command === undefined || command.includes(`${program} serve`)) {
|
|
172
|
+
this.verified = true;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
this.stop();
|
|
176
|
+
throw new Error(
|
|
177
|
+
`macOS started another copy of CuaDriver.app (${command.split(' serve')[0]}). Run \`${program} doctor --fix\`, which makes macOS forget the other copies, then try again.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
protected commandOf(pid: number): Promise<string | undefined> {
|
|
182
|
+
return new Promise(resolve =>
|
|
183
|
+
execFile('ps', ['-o', 'command=', '-p', String(pid)], (error, stdout) => resolve(error ? undefined : String(stdout).trim())),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
protected touch(): void {
|
|
188
|
+
clearTimeout(this.idle);
|
|
189
|
+
this.idle = setTimeout(() => this.stop(), this.options.idleMs ?? IDLE_MS);
|
|
190
|
+
this.idle.unref?.();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
protected pid(): number | undefined {
|
|
194
|
+
try {
|
|
195
|
+
return Number(readFileSync(this.pidFile, 'utf8').trim()) || undefined;
|
|
196
|
+
} catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
protected get stageDir(): string {
|
|
202
|
+
return this.options.stageDir ?? join(homedir(), 'Library', 'Application Support', 'Ta', 'cua-driver');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* macOS counts an app inside another app's bundle as part of that app: its permissions would be asked
|
|
207
|
+
* for, and kept, under Ta's name, and lost with each build of Ta. Its copy outside (a clone on APFS,
|
|
208
|
+
* which takes no space) is CuaDriver.app on its own, with the permissions the user gave Cua Driver.
|
|
209
|
+
*/
|
|
210
|
+
protected async stage(): Promise<void> {
|
|
211
|
+
if (this.app === this.source || existsSync(this.app)) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const versions = dirname(dirname(this.app));
|
|
215
|
+
const temp = `${dirname(this.app)}.part`;
|
|
216
|
+
rmSync(temp, { recursive: true, force: true });
|
|
217
|
+
mkdirSync(temp, { recursive: true });
|
|
218
|
+
await run('cp', ['-cR', this.source, temp]).catch(() => run('cp', ['-R', this.source, temp]));
|
|
219
|
+
rmSync(dirname(this.app), { recursive: true, force: true });
|
|
220
|
+
renameSync(temp, dirname(this.app));
|
|
221
|
+
// The copies of earlier versions, which Ta no longer comes with.
|
|
222
|
+
for (const name of readdirSync(versions)) {
|
|
223
|
+
if (join(versions, name) !== dirname(this.app)) {
|
|
224
|
+
rmSync(join(versions, name), { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
protected async start(): Promise<void> {
|
|
230
|
+
await this.stage();
|
|
231
|
+
mkdirSync(this.dir, { recursive: true });
|
|
232
|
+
// A socket file left by a daemon that did not stop cleanly, or by the one adopt() stopped.
|
|
233
|
+
rmSync(this.socket, { force: true });
|
|
234
|
+
this.verified = false;
|
|
235
|
+
await this.launch(this.app, ['serve', '--socket', this.socket, '--pid-file', this.pidFile], this.options.env ?? {});
|
|
236
|
+
if (!this.launched) {
|
|
237
|
+
this.launched = true;
|
|
238
|
+
process.once('exit', () => this.stop());
|
|
239
|
+
}
|
|
240
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
241
|
+
while (Date.now() < deadline) {
|
|
242
|
+
if (await this.alive()) {
|
|
243
|
+
return this.verify();
|
|
244
|
+
}
|
|
245
|
+
await new Promise(resolve => setTimeout(resolve, POLL_MS));
|
|
246
|
+
}
|
|
247
|
+
throw new Error(`Cua Driver did not start in ${START_TIMEOUT_MS / 1000} s: run \`${join(this.app, 'Contents/MacOS/cua-driver')} doctor\`.`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export namespace CuaDriverDaemon {
|
|
252
|
+
/** Whether an app is inside another app's bundle. */
|
|
253
|
+
export function nested(app: string): boolean {
|
|
254
|
+
return /\.app\//.test(dirname(app));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** CFBundleVersion of an app, which names its copy. */
|
|
258
|
+
export function version(app: string): string {
|
|
259
|
+
try {
|
|
260
|
+
return /<key>CFBundleVersion<\/key>\s*<string>([^<]+)<\/string>/.exec(readFileSync(join(app, 'Contents', 'Info.plist'), 'utf8'))?.[1] ?? 'current';
|
|
261
|
+
} catch {
|
|
262
|
+
return 'current';
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const LSREGISTER = '/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister';
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Through LaunchServices, in the background and as a new instance, so that it is the app that runs and
|
|
271
|
+
* macOS applies the app's permissions. LaunchServices picks a copy by bundle id, and other apps may ship
|
|
272
|
+
* their own CuaDriver.app (another version, whose relay would not match): registering this one first makes
|
|
273
|
+
* it launch this very copy.
|
|
274
|
+
*/
|
|
275
|
+
async function openApp(app: string, args: string[], env: Readonly<Record<string, string>>): Promise<void> {
|
|
276
|
+
await run(LSREGISTER, ['-f', app]).catch(() => undefined);
|
|
277
|
+
const vars = Object.entries(env).flatMap(([name, value]) => ['--env', `${name}=${value}`]);
|
|
278
|
+
await run('open', ['-g', '-n', '-a', app, ...vars, '--args', ...args]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function run(program: string, args: string[]): Promise<string> {
|
|
282
|
+
return new Promise((resolve, reject) => execFile(program, args, (error, stdout) => (error ? reject(error) : resolve(String(stdout)))));
|
|
283
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CuaDriver, type CuaDriverHost } from './cua-driver';
|
|
4
|
+
|
|
5
|
+
function host(platform: NodeJS.Platform, files: string[], path?: string, bundled?: string): CuaDriverHost & { looked: string[] } {
|
|
6
|
+
const looked: string[] = [];
|
|
7
|
+
return {
|
|
8
|
+
platform,
|
|
9
|
+
path,
|
|
10
|
+
bundled,
|
|
11
|
+
home: '/home/u',
|
|
12
|
+
looked,
|
|
13
|
+
isFile: file => {
|
|
14
|
+
looked.push(file);
|
|
15
|
+
return files.includes(file);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('CuaDriver', () => {
|
|
21
|
+
it('finds the program on PATH first, then where its installer puts it', () => {
|
|
22
|
+
const own = (path: string) => ({ path, bundled: false });
|
|
23
|
+
expect(CuaDriver.locate(host('linux', ['/opt/bin/cua-driver', '/home/u/.local/bin/cua-driver'], '/usr/bin:/opt/bin'))).toEqual(
|
|
24
|
+
own('/opt/bin/cua-driver'),
|
|
25
|
+
);
|
|
26
|
+
expect(CuaDriver.locate(host('linux', ['/home/u/.local/bin/cua-driver'], '/usr/bin'))).toEqual(own('/home/u/.local/bin/cua-driver'));
|
|
27
|
+
expect(CuaDriver.locate(host('linux', [], '/usr/bin'))).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('looks for the app on macOS, and for the .exe on Windows', () => {
|
|
31
|
+
const app = '/Applications/CuaDriver.app/Contents/MacOS/cua-driver';
|
|
32
|
+
expect(CuaDriver.locate(host('darwin', [app]))).toEqual({ path: app, bundled: false });
|
|
33
|
+
expect(host('linux', []).looked).toEqual([]);
|
|
34
|
+
const windows = host('win32', [], 'C:\\bin;D:\\tools');
|
|
35
|
+
CuaDriver.locate(windows);
|
|
36
|
+
expect(windows.looked).toEqual([
|
|
37
|
+
join('C:\\bin', 'cua-driver.exe'),
|
|
38
|
+
join('D:\\tools', 'cua-driver.exe'),
|
|
39
|
+
join('/home/u', '.local', 'bin', 'cua-driver.exe'),
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('prefers the one of Ta Desktop, a version Ta is tested with', () => {
|
|
44
|
+
const bundled = '/Ta.app/Contents/Resources/cua-driver/CuaDriver.app/Contents/MacOS/cua-driver';
|
|
45
|
+
expect(CuaDriver.locate(host('darwin', [bundled, '/home/u/.local/bin/cua-driver'], '/usr/bin', bundled))).toEqual({ path: bundled, bundled: true });
|
|
46
|
+
expect(CuaDriver.locate(host('darwin', ['/home/u/.local/bin/cua-driver'], '/usr/bin', bundled))).toEqual({
|
|
47
|
+
path: '/home/u/.local/bin/cua-driver',
|
|
48
|
+
bundled: false,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('tells the model what each system needs', () => {
|
|
53
|
+
expect(CuaDriver.prompt('win32')).toContain('administrator');
|
|
54
|
+
expect(CuaDriver.prompt('linux')).toContain('AT-SPI');
|
|
55
|
+
expect(CuaDriver.prompt('darwin')).toContain('permissions of macOS');
|
|
56
|
+
expect(CuaDriver.prompt('freebsd')).not.toContain('administrator');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('finds the app of a program inside one', () => {
|
|
60
|
+
expect(CuaDriver.appOf('/T/CuaDriver.app/Contents/MacOS/cua-driver')).toBe('/T/CuaDriver.app');
|
|
61
|
+
expect(CuaDriver.appOf('/T/bin/cua-driver')).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('stops at the first file it finds', () => {
|
|
65
|
+
const found = host('linux', ['/a/cua-driver'], '/a:/b:/c');
|
|
66
|
+
CuaDriver.locate(found);
|
|
67
|
+
expect(found.looked).toEqual(['/a/cua-driver']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is reached by a script that names its tools, or a search that names it', () => {
|
|
71
|
+
expect(CuaDriver.reaches(['await', 'tools', 'mcp__cua_driver__click'], {})).toBe(true);
|
|
72
|
+
expect(CuaDriver.reaches(['describeNamespace', 'mcp__cua_driver'], {})).toBe(true);
|
|
73
|
+
expect(CuaDriver.reaches(['tools', 'bash'], {})).toBe(false);
|
|
74
|
+
expect(CuaDriver.reaches('all', { code: 'searchTools("click")' })).toBe(false);
|
|
75
|
+
expect(CuaDriver.reaches('all', { code: 'describeNamespace("mcp__cua_driver")' })).toBe(true);
|
|
76
|
+
expect(CuaDriver.reaches(undefined, { code: 'mcp__cua_driver' })).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('says where the program is, or how to install it', () => {
|
|
80
|
+
expect(CuaDriver.status({ path: '/x/cua-driver', bundled: false }, 'darwin')).toContain('/x/cua-driver');
|
|
81
|
+
expect(CuaDriver.status({ path: '/x/cua-driver', bundled: true }, 'linux')).toContain('Ta Desktop');
|
|
82
|
+
expect(CuaDriver.status(undefined, 'linux')).toContain(CuaDriver.INSTALL.posix);
|
|
83
|
+
expect(CuaDriver.status(undefined, 'win32')).toContain(CuaDriver.INSTALL.win32);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** Where to look for Cua Driver: the parts of the system it depends on, so that tests can give their own. */
|
|
6
|
+
export interface CuaDriverHost {
|
|
7
|
+
platform: NodeJS.Platform;
|
|
8
|
+
/** The `PATH` environment variable. */
|
|
9
|
+
path?: string;
|
|
10
|
+
home: string;
|
|
11
|
+
/** {@link CuaDriver.BUNDLED_ENV}. */
|
|
12
|
+
bundled?: string;
|
|
13
|
+
isFile(path: string): boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The program of Cua Driver that Ta uses. */
|
|
17
|
+
export interface CuaDriverProgram {
|
|
18
|
+
path: string;
|
|
19
|
+
/** The one that comes with Ta Desktop, used when the user has none of their own. */
|
|
20
|
+
bundled: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cua Driver (https://cua.ai/cua-driver): a program that uses the desktop apps of this computer in the
|
|
25
|
+
* background, through each system's accessibility tree, as an MCP server (`cua-driver mcp`). Ta uses the
|
|
26
|
+
* one that comes with Ta Desktop, a version Ta is tested with, or else the one the user installed.
|
|
27
|
+
*/
|
|
28
|
+
export namespace CuaDriver {
|
|
29
|
+
/** The name of its MCP server, so its tools are `mcp__cua_driver__<tool>`. */
|
|
30
|
+
export const SERVER = 'cua-driver';
|
|
31
|
+
/** pi's namespace of the server's tools. */
|
|
32
|
+
export const NAMESPACE = 'mcp__cua_driver';
|
|
33
|
+
/** The section of the system prompt that tells the model about it. */
|
|
34
|
+
export const SECTION = 'computer_use';
|
|
35
|
+
/** The program that comes with Ta Desktop, which sets it for the `ta serve` it starts. */
|
|
36
|
+
export const BUNDLED_ENV = 'TA_CUA_DRIVER';
|
|
37
|
+
|
|
38
|
+
/** What the system prompt says, with what only one system needs. */
|
|
39
|
+
export function prompt(platform: NodeJS.Platform): string {
|
|
40
|
+
return [
|
|
41
|
+
"Cua Driver lets you use the desktop apps of this computer in the background, without taking the user's pointer or keyboard: list apps and windows, read a window's accessibility tree, click, type, scroll and take screenshots.",
|
|
42
|
+
`Its tools are \`tools.${NAMESPACE}__*\` in codemode scripts. It starts the first time a script names \`${NAMESPACE}\`: begin with \`describeNamespace("${NAMESPACE}")\` for its tools and how to use them.`,
|
|
43
|
+
'Use it only when a task needs a desktop app, not for files or commands.',
|
|
44
|
+
...(PLATFORM_NOTES[platform] ? [PLATFORM_NOTES[platform]] : []),
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PLATFORM_NOTES: Partial<Record<NodeJS.Platform, string>> = {
|
|
49
|
+
darwin: 'If a call waits for the permissions of macOS, Ta has asked the user for them; do not ask again.',
|
|
50
|
+
win32: 'Windows does not let it control the windows of apps run as administrator unless Ta runs as administrator too.',
|
|
51
|
+
linux: 'It needs the desktop session of the user (X11, or Wayland with XWayland) and its accessibility bus (AT-SPI).',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* For each run of the one of Ta Desktop: no product telemetry and no update check, which Ta's own
|
|
56
|
+
* updates take care of. The user's own install keeps the user's settings.
|
|
57
|
+
*/
|
|
58
|
+
export const BUNDLED_ENV_VARS: Readonly<Record<string, string>> = { CUA_DRIVER_RS_TELEMETRY_ENABLED: '0', CUA_DRIVER_RS_UPDATE_CHECK: '0' };
|
|
59
|
+
|
|
60
|
+
/** The description of the MCP server, which pi lists in the system prompt once it is registered. */
|
|
61
|
+
export const DESCRIPTION = 'Use the desktop apps of this computer in the background (Cua Driver)';
|
|
62
|
+
|
|
63
|
+
export const INSTALL: Readonly<Record<'posix' | 'win32', string>> = {
|
|
64
|
+
posix: '/bin/bash -c "$(curl -fsSL https://cua.ai/driver/install.sh)"',
|
|
65
|
+
win32: 'irm https://cua.ai/driver/install.ps1 | iex',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Its program: the one of Ta Desktop, else the user's own, on `PATH` or where its installer or its macOS
|
|
70
|
+
* app puts it. Only looks at files, it runs nothing.
|
|
71
|
+
*/
|
|
72
|
+
export function locate(host: CuaDriverHost = currentHost()): CuaDriverProgram | undefined {
|
|
73
|
+
if (host.bundled && host.isFile(host.bundled)) {
|
|
74
|
+
return { path: host.bundled, bundled: true };
|
|
75
|
+
}
|
|
76
|
+
const windows = host.platform === 'win32';
|
|
77
|
+
const name = windows ? 'cua-driver.exe' : 'cua-driver';
|
|
78
|
+
const dirs = (host.path ?? '').split(windows ? ';' : ':').filter(Boolean);
|
|
79
|
+
const candidates = [...dirs.map(dir => join(dir, name)), join(host.home, '.local', 'bin', name)];
|
|
80
|
+
if (host.platform === 'darwin') {
|
|
81
|
+
const app = 'CuaDriver.app/Contents/MacOS/cua-driver';
|
|
82
|
+
candidates.push(join('/Applications', app), join(host.home, 'Applications', app));
|
|
83
|
+
}
|
|
84
|
+
const own = candidates.find(candidate => host.isFile(candidate));
|
|
85
|
+
return own ? { path: own, bundled: false } : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The CuaDriver.app of a program inside one. */
|
|
89
|
+
export function appOf(program: string): string | undefined {
|
|
90
|
+
const inside = '/Contents/MacOS/cua-driver';
|
|
91
|
+
const app = program.slice(0, -inside.length);
|
|
92
|
+
return program.endsWith(inside) && app.endsWith('.app') ? app : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether a call may use its tools, given what the called tool says it reaches (pi's `reachesTools`):
|
|
97
|
+
* the words of a codemode script, or `all` for one that searches tools, which then has to name it.
|
|
98
|
+
*/
|
|
99
|
+
export function reaches(reach: 'all' | readonly string[] | undefined, input: unknown): boolean {
|
|
100
|
+
if (reach === undefined) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (reach === 'all') {
|
|
104
|
+
return JSON.stringify(input ?? null).includes(NAMESPACE);
|
|
105
|
+
}
|
|
106
|
+
return reach.some(word => word.includes(NAMESPACE));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What `/computer-use` says. */
|
|
110
|
+
export function status(program: CuaDriverProgram | undefined, platform: NodeJS.Platform): string {
|
|
111
|
+
if (program) {
|
|
112
|
+
const which = program.bundled ? `the one that comes with Ta Desktop (${program.path})` : program.path;
|
|
113
|
+
return `Cua Driver: ${which}. Ta starts it the first time it uses a desktop app, and checks what the system needs then.`;
|
|
114
|
+
}
|
|
115
|
+
const install = platform === 'win32' ? INSTALL.win32 : INSTALL.posix;
|
|
116
|
+
return `Ta uses desktop apps through Cua Driver, which is not installed. Install it with \`${install}\`, or use Ta Desktop, which comes with it; Ta finds it on the next message, with no restart.`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function currentHost(): CuaDriverHost {
|
|
120
|
+
return { platform: process.platform, path: process.env.PATH, home: homedir(), bundled: process.env[BUNDLED_ENV], isFile };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isFile(path: string): boolean {
|
|
124
|
+
try {
|
|
125
|
+
return statSync(path).isFile();
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|