@momasiku/dsh-pilot 0.0.0-stage → 0.5.7
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/LICENSE +21 -0
- package/README.md +466 -3
- package/cordis.patch.yml +19 -0
- package/lib/index.js +1567 -0
- package/lib/scripts/_dsh-action.ps1 +417 -0
- package/lib/scripts/_dsh-capture.ps1 +241 -0
- package/lib/scripts/_dsh-win32.ps1 +214 -0
- package/lib/scripts/desktop-action.ps1 +40 -0
- package/lib/scripts/desktop-probe.ps1 +43 -0
- package/lib/scripts/desktop-worker.ps1 +63 -0
- package/package.json +58 -6
package/lib/index.js
ADDED
|
@@ -0,0 +1,1567 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Real-time desktop vision and control for the DeepSeek Harness.
|
|
11
|
+
*
|
|
12
|
+
* Two model-facing tools share ONE pixel space. `screen_view` captures the live
|
|
13
|
+
* desktop and returns the PNG as a real image block, so the calling model sees
|
|
14
|
+
* the actual screen rather than a description of it. `desktop_control` drives
|
|
15
|
+
* the mouse, keyboard, and windows in the physical pixels that image is
|
|
16
|
+
* measured in, then optionally captures again, closing the see/act/see loop.
|
|
17
|
+
*
|
|
18
|
+
* Both halves run through PowerShell child processes:
|
|
19
|
+
* `lib/scripts/desktop-probe.ps1` (sensor) and `lib/scripts/desktop-action.ps1`
|
|
20
|
+
* (effector). Each child makes itself per-monitor DPI aware before it does
|
|
21
|
+
* anything, which is what keeps the reported geometry honest: DPI-unaware
|
|
22
|
+
* processes are lied to by Windows and would report a 2560x1600 panel as
|
|
23
|
+
* 1707x1067, making every click land in the wrong place.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-pilot
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Stable Loader identity. */
|
|
29
|
+
const name = 'pilot';
|
|
30
|
+
|
|
31
|
+
/** Validated plugin configuration. */
|
|
32
|
+
const Config = z.object({
|
|
33
|
+
/** How many screenshots to retain under the workspace capture directory. */
|
|
34
|
+
captureRetention: z.number().default(30),
|
|
35
|
+
/** Milliseconds allowed for one capture or one action before the child is killed. */
|
|
36
|
+
timeoutMs: z.number().default(20000),
|
|
37
|
+
/** Default settle delay between an action and its automatic capture. */
|
|
38
|
+
settleMs: z.number().default(750),
|
|
39
|
+
/** Per-step settle delay inside a desktop_sequence: enough for the UI to react, short enough to stay fast. */
|
|
40
|
+
stepSettleMs: z.number().default(120),
|
|
41
|
+
/** Hard cap on the steps one desktop_sequence call may run. */
|
|
42
|
+
maxSequenceSteps: z.number().default(24),
|
|
43
|
+
/** Keep one PowerShell process warm so an action costs milliseconds instead of a process start (~0.9 s). */
|
|
44
|
+
useWorker: z.boolean().default(true),
|
|
45
|
+
/** Draw coordinate rulers on every frame, so readings do not depend on counting pixels. */
|
|
46
|
+
rulers: z.boolean().default(true),
|
|
47
|
+
/** Also capture a frame when a tool result carries no image (still materializes the file). */
|
|
48
|
+
alwaysSaveFile: z.boolean().default(true)
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Services this plugin registers against. */
|
|
52
|
+
const inject = ['tools'];
|
|
53
|
+
|
|
54
|
+
/** Directory name for captured frames inside the session workspace. */
|
|
55
|
+
const CAPTURE_DIRNAME = '.dsh-pilot';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Absolute path of this package, resolved from the module URL rather than the
|
|
59
|
+
* working directory, so the helper scripts are found wherever the profile
|
|
60
|
+
* mounted the package from.
|
|
61
|
+
* @returns the package root directory.
|
|
62
|
+
*/
|
|
63
|
+
function packageRoot() {
|
|
64
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Path of the sensor script. */
|
|
68
|
+
function probeScript() {
|
|
69
|
+
return join(packageRoot(), 'lib', 'scripts', 'desktop-probe.ps1');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Path of the effector script. */
|
|
73
|
+
function actionScript() {
|
|
74
|
+
return join(packageRoot(), 'lib', 'scripts', 'desktop-action.ps1');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Path of the persistent worker (the warm path for actions). */
|
|
78
|
+
function workerScript() {
|
|
79
|
+
return join(packageRoot(), 'lib', 'scripts', 'desktop-worker.ps1');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Scratch directory for parameter files and cancel tokens. */
|
|
83
|
+
function scratchDir() {
|
|
84
|
+
const dir = join(resolve(process.env.TEMP ?? process.env.TMP ?? packageRoot()), 'dsh-pilot');
|
|
85
|
+
mkdirSync(dir, { recursive: true });
|
|
86
|
+
return dir;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let sequence = 0;
|
|
90
|
+
|
|
91
|
+
/** Unique-enough file stem for one invocation. */
|
|
92
|
+
function nextToken() {
|
|
93
|
+
sequence += 1;
|
|
94
|
+
return `${Date.now().toString(36)}-${process.pid.toString(36)}-${sequence.toString(36)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run one PowerShell helper to completion.
|
|
99
|
+
*
|
|
100
|
+
* Parameters travel through a UTF-8 file rather than the command line, so
|
|
101
|
+
* non-ASCII window titles and paths survive regardless of the console code
|
|
102
|
+
* page. When the caller's signal aborts, the cancel token is stamped and the
|
|
103
|
+
* child is killed, which is what makes an interrupted turn stop a gesture in
|
|
104
|
+
* flight instead of letting the remaining clicks play out.
|
|
105
|
+
*
|
|
106
|
+
* @param script - absolute path of the helper script.
|
|
107
|
+
* @param params - parameter object serialized as JSON for the child.
|
|
108
|
+
* @param signal - the tool execution signal.
|
|
109
|
+
* @param timeoutMs - hard kill deadline in milliseconds.
|
|
110
|
+
* @returns the parsed JSON result object.
|
|
111
|
+
*/
|
|
112
|
+
function runHelper(script, params, signal, timeoutMs) {
|
|
113
|
+
if (!existsSync(script)) throw new Error(`pilot: helper script is missing at ${script}`);
|
|
114
|
+
const dir = scratchDir();
|
|
115
|
+
const token = nextToken();
|
|
116
|
+
const paramsPath = join(dir, `params-${token}.json`);
|
|
117
|
+
const cancelPath = join(dir, `cancel-${token}.txt`);
|
|
118
|
+
writeFileSync(paramsPath, JSON.stringify({ ...params, cancelFile: cancelPath }), 'utf8');
|
|
119
|
+
|
|
120
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
121
|
+
const child = spawn(
|
|
122
|
+
'powershell.exe',
|
|
123
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, '-ParamsPath', paramsPath],
|
|
124
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }
|
|
125
|
+
);
|
|
126
|
+
let stdout = '';
|
|
127
|
+
let stderr = '';
|
|
128
|
+
let settled = false;
|
|
129
|
+
let timer;
|
|
130
|
+
|
|
131
|
+
const cleanup = () => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
for (const file of [paramsPath, cancelPath]) {
|
|
134
|
+
try {
|
|
135
|
+
rmSync(file, { force: true });
|
|
136
|
+
} catch {
|
|
137
|
+
// A leftover scratch file is harmless.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const finish = (fn, value) => {
|
|
143
|
+
if (settled) return;
|
|
144
|
+
settled = true;
|
|
145
|
+
cleanup();
|
|
146
|
+
fn(value);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const abort = () => {
|
|
150
|
+
try {
|
|
151
|
+
writeFileSync(cancelPath, 'stop', 'utf8');
|
|
152
|
+
} catch {
|
|
153
|
+
// The kill below is the authoritative stop.
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
child.kill();
|
|
157
|
+
} catch {
|
|
158
|
+
// Already gone.
|
|
159
|
+
}
|
|
160
|
+
finish(rejectPromise, new Error('pilot: the action was cancelled before it completed'));
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (signal !== undefined) {
|
|
164
|
+
if (signal.aborted) {
|
|
165
|
+
finish(rejectPromise, new Error('pilot: the action was cancelled before it started'));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
timer = setTimeout(() => {
|
|
172
|
+
try {
|
|
173
|
+
child.kill();
|
|
174
|
+
} catch {
|
|
175
|
+
// Already gone.
|
|
176
|
+
}
|
|
177
|
+
finish(rejectPromise, new Error(`pilot: the helper did not finish within ${timeoutMs} ms`));
|
|
178
|
+
}, timeoutMs);
|
|
179
|
+
|
|
180
|
+
child.stdout.on('data', (chunk) => {
|
|
181
|
+
stdout += chunk.toString('utf8');
|
|
182
|
+
});
|
|
183
|
+
child.stderr.on('data', (chunk) => {
|
|
184
|
+
stderr += chunk.toString('utf8');
|
|
185
|
+
});
|
|
186
|
+
child.on('error', (error) => {
|
|
187
|
+
finish(rejectPromise, new Error(`pilot: could not start PowerShell (${error.message})`));
|
|
188
|
+
});
|
|
189
|
+
child.on('close', (code) => {
|
|
190
|
+
if (settled) return;
|
|
191
|
+
signal?.removeEventListener('abort', abort);
|
|
192
|
+
const payload = parseLastJson(stdout);
|
|
193
|
+
if (payload === undefined) {
|
|
194
|
+
const detail = stderr.trim().length > 0 ? stderr.trim() : `exit code ${code}`;
|
|
195
|
+
finish(rejectPromise, new Error(`pilot: the helper produced no result (${detail})`));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (payload.ok !== true) {
|
|
199
|
+
finish(rejectPromise, new Error(String(payload.error ?? 'pilot: the helper reported a failure')));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
finish(resolvePromise, payload);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read the last JSON object a helper printed. Helpers may emit assembly chatter
|
|
209
|
+
* before their result, so the parse walks backwards line by line.
|
|
210
|
+
* @param text - raw stdout.
|
|
211
|
+
* @returns the parsed object, or undefined.
|
|
212
|
+
*/
|
|
213
|
+
function parseLastJson(text) {
|
|
214
|
+
const lines = text.split(/\r?\n/u);
|
|
215
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
216
|
+
const line = lines[index].trim();
|
|
217
|
+
if (!line.startsWith('{') || !line.endsWith('}')) continue;
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(line);
|
|
220
|
+
} catch {
|
|
221
|
+
// Keep looking further up.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Options accepted by both helpers. */
|
|
228
|
+
function optionsFor(exec, config) {
|
|
229
|
+
return {
|
|
230
|
+
signal: exec.signal,
|
|
231
|
+
timeoutMs: config.timeoutMs
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* One long-lived PowerShell process that keeps the Win32 bridge compiled.
|
|
237
|
+
*
|
|
238
|
+
* Starting a process and compiling that bridge costs ~0.9 s, which a batch of
|
|
239
|
+
* five steps used to pay five times. The worker pays it once and then answers a
|
|
240
|
+
* request in single-digit milliseconds, so the cost of an action becomes the
|
|
241
|
+
* action itself.
|
|
242
|
+
*
|
|
243
|
+
* Requests are serialized (one in flight, ids echoed back) because a single
|
|
244
|
+
* stdout stream cannot be demultiplexed any other way. A request that times out
|
|
245
|
+
* kills the worker: an action whose fate is unknown must not be retried silently,
|
|
246
|
+
* and the next request starts a fresh worker.
|
|
247
|
+
*/
|
|
248
|
+
class ActionWorker {
|
|
249
|
+
constructor() {
|
|
250
|
+
this.child = undefined;
|
|
251
|
+
this.ready = false;
|
|
252
|
+
this.failed = false;
|
|
253
|
+
this.starting = undefined;
|
|
254
|
+
this.waiters = [];
|
|
255
|
+
this.queue = Promise.resolve();
|
|
256
|
+
this.sequence = 0;
|
|
257
|
+
this.buffer = '';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** True while the worker is worth trying (it can still be started). */
|
|
261
|
+
usable() {
|
|
262
|
+
return !this.failed;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Start the process once and wait for its ready line.
|
|
267
|
+
* @param timeoutMs - how long the first start may take (the bridge compiles).
|
|
268
|
+
*/
|
|
269
|
+
async start(timeoutMs) {
|
|
270
|
+
if (this.ready) return;
|
|
271
|
+
if (this.failed) throw Object.assign(new Error('pilot: the action worker is unavailable'), { transport: true });
|
|
272
|
+
if (this.starting !== undefined) return this.starting;
|
|
273
|
+
this.starting = new Promise((resolvePromise, rejectPromise) => {
|
|
274
|
+
const settle = (error) => {
|
|
275
|
+
this.failed = true;
|
|
276
|
+
this.starting = undefined;
|
|
277
|
+
try {
|
|
278
|
+
this.child?.kill();
|
|
279
|
+
} catch {
|
|
280
|
+
// Already gone.
|
|
281
|
+
}
|
|
282
|
+
rejectPromise(Object.assign(error, { transport: true }));
|
|
283
|
+
};
|
|
284
|
+
const timer = setTimeout(() => settle(new Error(`pilot: the action worker did not start within ${timeoutMs} ms`)), timeoutMs);
|
|
285
|
+
let child;
|
|
286
|
+
try {
|
|
287
|
+
child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', workerScript()], {
|
|
288
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
289
|
+
windowsHide: true
|
|
290
|
+
});
|
|
291
|
+
} catch (error) {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
settle(new Error(`pilot: could not start the action worker (${error.message})`));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
this.child = child;
|
|
297
|
+
child.stdout.setEncoding('utf8');
|
|
298
|
+
child.stdout.on('data', (chunk) => this.absorb(chunk));
|
|
299
|
+
child.stderr.setEncoding('utf8');
|
|
300
|
+
child.stderr.on('data', () => {
|
|
301
|
+
// Stray stderr is not fatal: the protocol lives on stdout.
|
|
302
|
+
});
|
|
303
|
+
child.on('error', (error) => {
|
|
304
|
+
clearTimeout(timer);
|
|
305
|
+
settle(new Error(`pilot: the action worker failed (${error.message})`));
|
|
306
|
+
});
|
|
307
|
+
child.on('close', () => {
|
|
308
|
+
this.ready = false;
|
|
309
|
+
this.child = undefined;
|
|
310
|
+
const pending = this.waiters;
|
|
311
|
+
this.waiters = [];
|
|
312
|
+
for (const waiter of pending) {
|
|
313
|
+
// The action may or may not have run: never retry it silently.
|
|
314
|
+
waiter.reject(Object.assign(new Error('pilot: the action worker exited before answering'), { transport: false }));
|
|
315
|
+
}
|
|
316
|
+
if (!this.ready) this.failed = true;
|
|
317
|
+
});
|
|
318
|
+
this.readyResolve = () => {
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
this.ready = true;
|
|
321
|
+
resolvePromise();
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
return this.starting;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Feed stdout through the line protocol. */
|
|
328
|
+
absorb(chunk) {
|
|
329
|
+
this.buffer += chunk;
|
|
330
|
+
let index = this.buffer.indexOf('\n');
|
|
331
|
+
while (index >= 0) {
|
|
332
|
+
const line = this.buffer.slice(0, index).trim();
|
|
333
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
334
|
+
if (line.length > 0) this.dispatch(line);
|
|
335
|
+
index = this.buffer.indexOf('\n');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Hand one response line to its waiter. */
|
|
340
|
+
dispatch(line) {
|
|
341
|
+
let payload;
|
|
342
|
+
try {
|
|
343
|
+
payload = JSON.parse(line);
|
|
344
|
+
} catch {
|
|
345
|
+
return; // Assembly chatter is not our business.
|
|
346
|
+
}
|
|
347
|
+
if (payload !== null && typeof payload === 'object' && payload.ready === true) {
|
|
348
|
+
this.readyResolve?.();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const waiter = payload?.id === undefined
|
|
352
|
+
? this.waiters.shift()
|
|
353
|
+
: this.waiters.findIndex((entry) => entry.id === payload.id);
|
|
354
|
+
const claim = typeof waiter === 'number' ? (waiter < 0 ? undefined : this.waiters.splice(waiter, 1)[0]) : waiter;
|
|
355
|
+
if (claim === undefined) return;
|
|
356
|
+
claim.settle(payload);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Run one action in the warm process.
|
|
361
|
+
* @param payload - the action request (cancelFile included by the caller).
|
|
362
|
+
* @param options - signal and timeout.
|
|
363
|
+
* @returns the parsed result of a successful action.
|
|
364
|
+
*/
|
|
365
|
+
request(payload, options) {
|
|
366
|
+
const run = async () => {
|
|
367
|
+
const started = Date.now();
|
|
368
|
+
await this.start(Math.max(options.timeoutMs, 30000));
|
|
369
|
+
const id = `r${(this.sequence += 1)}`;
|
|
370
|
+
const budget = Math.max(1000, options.timeoutMs - (Date.now() - started));
|
|
371
|
+
return await this.exchange({ ...payload, id }, id, budget, options);
|
|
372
|
+
};
|
|
373
|
+
const result = this.queue.then(run, run);
|
|
374
|
+
this.queue = result.then(() => undefined, () => undefined);
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Write one request and await its response. */
|
|
379
|
+
exchange(payload, id, timeoutMs, options) {
|
|
380
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
381
|
+
let settled = false;
|
|
382
|
+
const finish = (fn, value) => {
|
|
383
|
+
if (settled) return;
|
|
384
|
+
settled = true;
|
|
385
|
+
clearTimeout(timer);
|
|
386
|
+
options.signal?.removeEventListener('abort', abort);
|
|
387
|
+
const index = this.waiters.indexOf(waiter);
|
|
388
|
+
if (index >= 0) this.waiters.splice(index, 1);
|
|
389
|
+
fn(value);
|
|
390
|
+
};
|
|
391
|
+
const waiter = {
|
|
392
|
+
id,
|
|
393
|
+
settle: (value) => {
|
|
394
|
+
if (value.ok === true) finish(resolvePromise, value);
|
|
395
|
+
else finish(rejectPromise, Object.assign(new Error(String(value.error ?? 'pilot: the action failed')), { cancelled: value.cancelled === true }));
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const abort = () => {
|
|
399
|
+
// The worker polls the cancel file between steps, so an
|
|
400
|
+
// interrupted turn stops a gesture in flight.
|
|
401
|
+
try {
|
|
402
|
+
if (typeof payload.cancelFile === 'string') writeFileSync(payload.cancelFile, 'stop', 'utf8');
|
|
403
|
+
} catch {
|
|
404
|
+
// The kill below is the authoritative stop.
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
const timer = setTimeout(() => {
|
|
408
|
+
// Unknown fate: kill the worker so nothing keeps acting, and let
|
|
409
|
+
// the caller see the failure rather than a silent retry.
|
|
410
|
+
try {
|
|
411
|
+
this.child?.kill();
|
|
412
|
+
} catch {
|
|
413
|
+
// Already gone.
|
|
414
|
+
}
|
|
415
|
+
finish(rejectPromise, Object.assign(new Error(`pilot: the action did not finish within ${timeoutMs} ms`), { transport: false }));
|
|
416
|
+
}, timeoutMs);
|
|
417
|
+
this.waiters.push(waiter);
|
|
418
|
+
if (options.signal !== undefined) {
|
|
419
|
+
if (options.signal.aborted) abort();
|
|
420
|
+
else options.signal.addEventListener('abort', abort, { once: true });
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
this.child.stdin.write(`${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')}\n`);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
// Nothing reached the child, so a fallback cannot double-act.
|
|
426
|
+
finish(rejectPromise, Object.assign(new Error(`pilot: could not reach the action worker (${error.message})`), { transport: true }));
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Ask the worker to exit; a worker that ignores it is killed. */
|
|
432
|
+
async stop() {
|
|
433
|
+
const child = this.child;
|
|
434
|
+
if (child === undefined) return;
|
|
435
|
+
try {
|
|
436
|
+
child.stdin.write(`${Buffer.from('{"quit":true}', 'utf8').toString('base64')}\n`);
|
|
437
|
+
} catch {
|
|
438
|
+
// Fall through to the kill.
|
|
439
|
+
}
|
|
440
|
+
await new Promise((resolvePromise) => {
|
|
441
|
+
const timer = setTimeout(() => {
|
|
442
|
+
try {
|
|
443
|
+
child.kill();
|
|
444
|
+
} catch {
|
|
445
|
+
// Already gone.
|
|
446
|
+
}
|
|
447
|
+
resolvePromise();
|
|
448
|
+
}, 1500);
|
|
449
|
+
child.once('close', () => {
|
|
450
|
+
clearTimeout(timer);
|
|
451
|
+
resolvePromise();
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
this.ready = false;
|
|
455
|
+
this.child = undefined;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The warm action process, shared by every session in this host process. */
|
|
460
|
+
const actionWorker = new ActionWorker();
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Start the warm process in the background on first use.
|
|
464
|
+
*
|
|
465
|
+
* The ~1 s it needs (process start plus one C# compile) then overlaps whatever the
|
|
466
|
+
* current call is already doing — usually a capture — instead of being paid by the
|
|
467
|
+
* first action of the first batch. Failures stay silent here: each action falls
|
|
468
|
+
* back to the one-shot script on its own.
|
|
469
|
+
*
|
|
470
|
+
* @param config - validated plugin configuration.
|
|
471
|
+
*/
|
|
472
|
+
function warmWorker(config) {
|
|
473
|
+
if (config.useWorker === false) return;
|
|
474
|
+
void actionWorker.start(30000).catch(() => undefined);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Run one action: through the warm worker when it is available, otherwise through
|
|
479
|
+
* the one-shot script, which is also the fallback when the worker cannot start.
|
|
480
|
+
* A failure that happened *after* the request was delivered is never retried —
|
|
481
|
+
* the action may already have taken effect.
|
|
482
|
+
*
|
|
483
|
+
* @param params - the action request.
|
|
484
|
+
* @param config - validated plugin configuration.
|
|
485
|
+
* @param options - signal and timeout.
|
|
486
|
+
* @returns the parsed result.
|
|
487
|
+
*/
|
|
488
|
+
async function runAction(params, config, options) {
|
|
489
|
+
if (config.useWorker === false || !actionWorker.usable()) {
|
|
490
|
+
return runHelper(actionScript(), params, options.signal, options.timeoutMs);
|
|
491
|
+
}
|
|
492
|
+
const cancelPath = join(scratchDir(), `cancel-${nextToken()}.txt`);
|
|
493
|
+
try {
|
|
494
|
+
return await actionWorker.request({ ...params, cancelFile: cancelPath }, options);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
if (error?.transport === true) {
|
|
497
|
+
return runHelper(actionScript(), params, options.signal, options.timeoutMs);
|
|
498
|
+
}
|
|
499
|
+
throw error;
|
|
500
|
+
} finally {
|
|
501
|
+
try {
|
|
502
|
+
rmSync(cancelPath, { force: true });
|
|
503
|
+
} catch {
|
|
504
|
+
// A leftover cancel file is harmless: the token is unique.
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Run one capture: through the warm worker when it is available, otherwise
|
|
511
|
+
* through the one-shot probe.
|
|
512
|
+
*
|
|
513
|
+
* Unlike an action, a capture has no side effects, so any worker failure — a dead
|
|
514
|
+
* worker, a timeout, a protocol surprise — is safe to retry through the fallback.
|
|
515
|
+
*
|
|
516
|
+
* @param params - the capture request.
|
|
517
|
+
* @param config - validated plugin configuration.
|
|
518
|
+
* @param options - signal and timeout.
|
|
519
|
+
* @returns the parsed result.
|
|
520
|
+
*/
|
|
521
|
+
async function runCapture(params, config, options) {
|
|
522
|
+
if (config.useWorker === false || !actionWorker.usable()) {
|
|
523
|
+
return runHelper(probeScript(), params, options.signal, options.timeoutMs);
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
return await actionWorker.request({ ...params, kind: 'capture' }, options);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (error?.cancelled === true) throw error;
|
|
529
|
+
return runHelper(probeScript(), params, options.signal, options.timeoutMs);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Resolve (and create) the capture directory for one session.
|
|
535
|
+
* @param exec - tool execution context.
|
|
536
|
+
* @returns the absolute capture directory.
|
|
537
|
+
*/
|
|
538
|
+
function captureDir(exec) {
|
|
539
|
+
const cwd = exec.agent?.session.header.cwd;
|
|
540
|
+
const base = typeof cwd === 'string' && cwd.length > 0 ? cwd : scratchDir();
|
|
541
|
+
const dir = join(base, CAPTURE_DIRNAME);
|
|
542
|
+
mkdirSync(dir, { recursive: true });
|
|
543
|
+
return dir;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Delete the oldest frames beyond the retention limit. Attachments are stored
|
|
548
|
+
* content-addressed elsewhere, so pruning a frame never breaks an image the
|
|
549
|
+
* conversation already carries.
|
|
550
|
+
* @param dir - capture directory.
|
|
551
|
+
* @param retention - maximum frames to keep.
|
|
552
|
+
*/
|
|
553
|
+
function pruneCaptures(dir, retention) {
|
|
554
|
+
if (!Number.isFinite(retention) || retention < 1) return;
|
|
555
|
+
try {
|
|
556
|
+
const entries = readdirSync(dir)
|
|
557
|
+
.filter((entry) => entry.toLowerCase().endsWith('.png'))
|
|
558
|
+
.map((entry) => {
|
|
559
|
+
const full = join(dir, entry);
|
|
560
|
+
try {
|
|
561
|
+
return { full, mtime: statSync(full).mtimeMs };
|
|
562
|
+
} catch {
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
})
|
|
566
|
+
.filter((entry) => entry !== undefined)
|
|
567
|
+
.sort((left, right) => right.mtime - left.mtime);
|
|
568
|
+
for (const entry of entries.slice(retention)) {
|
|
569
|
+
try {
|
|
570
|
+
rmSync(entry.full, { force: true });
|
|
571
|
+
} catch {
|
|
572
|
+
// A frame still open in a viewer stays until the next prune.
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} catch {
|
|
576
|
+
// Pruning is housekeeping and never fails a capture.
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Capture parameters shared by both tools. */
|
|
581
|
+
function captureParams(args) {
|
|
582
|
+
const params = { includeCursor: true };
|
|
583
|
+
if (typeof args.screen === 'string' && args.screen.trim().length > 0) params.screen = args.screen.trim();
|
|
584
|
+
if (typeof args.region === 'string' && args.region.trim().length > 0) params.region = args.region.trim();
|
|
585
|
+
if (typeof args.window === 'string' && args.window.trim().length > 0) params.window = args.window.trim();
|
|
586
|
+
if (args.includeCursor === false) params.includeCursor = false;
|
|
587
|
+
return params;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Perform one capture and, when an attachment store is mounted, commit the
|
|
592
|
+
* frame as an image the conversation can carry.
|
|
593
|
+
* @param ctx - plugin context.
|
|
594
|
+
* @param exec - tool execution context.
|
|
595
|
+
* @param config - validated plugin configuration.
|
|
596
|
+
* @param args - the tool's capture arguments.
|
|
597
|
+
* @returns the structured capture outcome.
|
|
598
|
+
*/
|
|
599
|
+
async function capture(ctx, exec, config, args) {
|
|
600
|
+
const dir = captureDir(exec);
|
|
601
|
+
const file = join(dir, `screen-${nextToken()}.png`);
|
|
602
|
+
const options = optionsFor(exec, config);
|
|
603
|
+
const probe = await runCapture({ ...captureParams(args), rulers: args.rulers ?? config.rulers, out: file }, config, options);
|
|
604
|
+
pruneCaptures(dir, config.captureRetention);
|
|
605
|
+
|
|
606
|
+
const result = {
|
|
607
|
+
path: probe.path,
|
|
608
|
+
kind: probe.kind,
|
|
609
|
+
imageWidth: probe.imageWidth,
|
|
610
|
+
imageHeight: probe.imageHeight,
|
|
611
|
+
originX: probe.originX,
|
|
612
|
+
originY: probe.originY,
|
|
613
|
+
scale: probe.scale,
|
|
614
|
+
screens: (probe.screens ?? []).map(screenEntryOf),
|
|
615
|
+
cursor: probe.cursor,
|
|
616
|
+
foregroundWindow: windowInfoOf(probe.foregroundWindow),
|
|
617
|
+
...(probe.capturedAt === undefined ? {} : { capturedAt: probe.capturedAt })
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Frame deduplication, decided BEFORE the attachment is committed: a picture
|
|
621
|
+
// identical to the one this session already received is not worth another
|
|
622
|
+
// image block (nor a second copy in the store), unless the caller insists.
|
|
623
|
+
const hash = frameHashOf(probe.path);
|
|
624
|
+
if (hash !== undefined) {
|
|
625
|
+
result.frameHash = hash;
|
|
626
|
+
const key = exec?.agent?.session?.header?.sessionId ?? '__global__';
|
|
627
|
+
const previous = lastFrameHashes.get(key);
|
|
628
|
+
lastFrameHashes.set(key, hash);
|
|
629
|
+
if (previous === hash && args.forceImage !== true) {
|
|
630
|
+
result.unchanged = true;
|
|
631
|
+
result.imageSkipped = 'identical to the previous frame of this session, so it was not attached again; pass forceImage: true to see it anyway';
|
|
632
|
+
return result;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const attachments = ctx.get('attachments');
|
|
637
|
+
if (attachments === undefined || !config.alwaysSaveFile && args.withImage === false) return result;
|
|
638
|
+
|
|
639
|
+
const limits = attachments.imageLimits;
|
|
640
|
+
const bytes = readFileSync(probe.path);
|
|
641
|
+
if (bytes.byteLength > Math.min(limits.maxImageBytes, limits.maxMessageImageBytes)) {
|
|
642
|
+
result.imageError = `the frame is ${bytes.byteLength} bytes, above this deployment's per-image limit; narrow it with a region or a window capture`;
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
const ref = await attachments.saveImage({ data: new Uint8Array(bytes), mediaType: 'image/png', name: `screen-${nextToken()}.png` });
|
|
648
|
+
result.image = {
|
|
649
|
+
attachmentId: ref.attachmentId,
|
|
650
|
+
mediaType: ref.mediaType,
|
|
651
|
+
bytes: ref.bytes,
|
|
652
|
+
width: ref.width,
|
|
653
|
+
height: ref.height,
|
|
654
|
+
...(ref.name === undefined ? {} : { name: ref.name }),
|
|
655
|
+
...(ref.originalDimensions === undefined ? {} : { originalDimensions: { ...ref.originalDimensions } })
|
|
656
|
+
};
|
|
657
|
+
} catch (error) {
|
|
658
|
+
result.imageError = `the frame could not be attached: ${error instanceof Error ? error.message : String(error)}`;
|
|
659
|
+
}
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Enforce the image-input capability gate for the calling route. */
|
|
664
|
+
async function assertImageCapableRoute(ctx, exec, what) {
|
|
665
|
+
const routed = exec.agent?.session.requestHeader()?.config;
|
|
666
|
+
const provider = routed?.provider ?? exec.agent?.options.provider;
|
|
667
|
+
const model = routed?.model ?? exec.agent?.options.model;
|
|
668
|
+
const llm = ctx.get('llm');
|
|
669
|
+
if (provider === undefined || model === undefined || llm === undefined) {
|
|
670
|
+
throw new Error(`${what} needs an image-capable model route, and the current route could not be resolved; switch to a model that accepts image input`);
|
|
671
|
+
}
|
|
672
|
+
const active = await llm.resolveModelInfo(provider, model, exec.signal);
|
|
673
|
+
if (active.inputModalities === undefined || !active.inputModalities.includes('image')) {
|
|
674
|
+
throw new Error(`${what} needs a model that accepts image input, but "${model}" does not declare it; switch models to see the screen`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Render one capture as the model-facing envelope plus its image block. */
|
|
679
|
+
function captureContent(value) {
|
|
680
|
+
const screens = Array.isArray(value.screens) && value.screens.length > 0
|
|
681
|
+
? value.screens.map((screen) => `${screen.primary ? 'primary ' : ''}${screen.device} ${screen.bounds}`).join('; ')
|
|
682
|
+
: 'unknown';
|
|
683
|
+
const cursor = value.cursor === null || value.cursor === undefined
|
|
684
|
+
? 'unavailable'
|
|
685
|
+
: `${value.cursor.x},${value.cursor.y}`;
|
|
686
|
+
const foreground = value.foregroundWindow === null || value.foregroundWindow === undefined
|
|
687
|
+
? 'none'
|
|
688
|
+
: `"${value.foregroundWindow.title}" (${value.foregroundWindow.process}, class ${value.foregroundWindow.class}) at ${value.foregroundWindow.bounds}`;
|
|
689
|
+
const delivered = value.image;
|
|
690
|
+
const scale = deliveredScale(value);
|
|
691
|
+
// The size that matters to the model is the one it is looking at. The capture
|
|
692
|
+
// size is still reported when the store delivered something smaller, together
|
|
693
|
+
// with the ratio, because every coordinate read off the image has to be scaled
|
|
694
|
+
// by it before it becomes a screen pixel.
|
|
695
|
+
const sizeLine = delivered === undefined
|
|
696
|
+
? `<image_size delivered="none" captured="${value.imageWidth}x${value.imageHeight}">${value.imageWidth}x${value.imageHeight}</image_size>`
|
|
697
|
+
: (scaleIsOne(scale)
|
|
698
|
+
? `<image_size>${delivered.width}x${delivered.height}</image_size>`
|
|
699
|
+
: `<image_size delivered="${delivered.width}x${delivered.height}" captured="${value.imageWidth}x${value.imageHeight}">${delivered.width}x${delivered.height}</image_size>`);
|
|
700
|
+
const lines = [
|
|
701
|
+
'<screen_capture>',
|
|
702
|
+
`<kind>${value.kind}</kind>`,
|
|
703
|
+
sizeLine,
|
|
704
|
+
`<physical_origin>${value.originX},${value.originY}</physical_origin>`,
|
|
705
|
+
`<scale>${value.scale}</scale>`,
|
|
706
|
+
`<screens>${screens}</screens>`,
|
|
707
|
+
`<cursor>${cursor}</cursor>`,
|
|
708
|
+
`<foreground_window>${foreground}</foreground_window>`,
|
|
709
|
+
`<file>${value.path}</file>`
|
|
710
|
+
];
|
|
711
|
+
if (!scaleIsOne(scale)) {
|
|
712
|
+
lines.push(`<delivered_scale>${deliveredScaleNote(delivered, scale)}</delivered_scale>`);
|
|
713
|
+
}
|
|
714
|
+
if (value.rulers === true) {
|
|
715
|
+
lines.push('<rulers>ticks along the top and left edge, labelled with screen coordinates, so you can read a position without counting pixels</rulers>');
|
|
716
|
+
}
|
|
717
|
+
if (value.imageError !== undefined) lines.push(`<image_error>${value.imageError}</image_error>`);
|
|
718
|
+
if (value.imageSkipped !== undefined) lines.push(`<image_skipped>${value.imageSkipped}</image_skipped>`);
|
|
719
|
+
lines.push('</screen_capture>');
|
|
720
|
+
if (delivered !== undefined) lines.push(coordinateContract(value));
|
|
721
|
+
else if (value.unchanged === true) lines.push('Nothing changed since the last frame of this session, so the image you already have still applies — including its coordinates.');
|
|
722
|
+
const blocks = [{ type: 'text', text: lines.join('\n') }];
|
|
723
|
+
if (value.image !== undefined) blocks.push({ type: 'image', attachment: value.image });
|
|
724
|
+
return blocks;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* The pixel-space contract, restated next to every frame the model receives.
|
|
729
|
+
*
|
|
730
|
+
* It has to be conditional, because the attachment store may deliver a frame
|
|
731
|
+
* smaller than the capture (it re-encodes within its own byte limits). A model that
|
|
732
|
+
* reads a coordinate off a shrunk image and passes it through unchanged clicks the
|
|
733
|
+
* wrong place, so when the two sizes differ this says the ratio out loud instead of
|
|
734
|
+
* claiming 1:1.
|
|
735
|
+
*
|
|
736
|
+
* @param value - a capture, or a tool result carrying one under `capture`.
|
|
737
|
+
* @returns the contract text for that frame.
|
|
738
|
+
*/
|
|
739
|
+
function coordinateContract(value) {
|
|
740
|
+
const capture = value?.capture ?? value;
|
|
741
|
+
const scale = deliveredScale(capture);
|
|
742
|
+
if (scaleIsOne(scale)) {
|
|
743
|
+
return [
|
|
744
|
+
'Image pixel (0,0) is the top-left of this frame. Both halves of this device are per-monitor DPI aware and this frame is delivered at capture size, so one image pixel is one real screen pixel:',
|
|
745
|
+
'to act on a feature at image (x,y), call desktop_control with exactly x and y. Do not rescale for display scaling.'
|
|
746
|
+
].join(' ');
|
|
747
|
+
}
|
|
748
|
+
if (axesAgree(scale)) {
|
|
749
|
+
const ratio = (scale.x + scale.y) / 2;
|
|
750
|
+
return [
|
|
751
|
+
`This frame was delivered at ${printed(ratio)}x the capture, so image pixels are NOT screen pixels:`,
|
|
752
|
+
`to act on a feature at image (x,y), multiply both readings by ${printed(1 / ratio)} and pass those physical screen pixels to desktop_control.`
|
|
753
|
+
].join(' ');
|
|
754
|
+
}
|
|
755
|
+
return [
|
|
756
|
+
`This frame was delivered at ${printed(scale.x)}x horizontally and ${printed(scale.y)}x vertically of the capture, so image pixels are NOT screen pixels:`,
|
|
757
|
+
`to act on a feature at image (x,y), multiply the readings by ${printed(1 / scale.x)} and ${printed(1 / scale.y)} (x*${printed(1 / scale.x)}, y*${printed(1 / scale.y)}) and pass those physical screen pixels to desktop_control.`
|
|
758
|
+
].join(' ');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* How the delivered image relates to the captured frame.
|
|
763
|
+
* @param value - the capture outcome.
|
|
764
|
+
* @returns per-axis delivered/captured ratios, or undefined when there is no frame.
|
|
765
|
+
*/
|
|
766
|
+
function deliveredScale(value) {
|
|
767
|
+
const delivered = value?.image;
|
|
768
|
+
if (delivered === undefined || delivered === null) return undefined;
|
|
769
|
+
const capturedWidth = Number(value.imageWidth);
|
|
770
|
+
const capturedHeight = Number(value.imageHeight);
|
|
771
|
+
const deliveredWidth = Number(delivered.width);
|
|
772
|
+
const deliveredHeight = Number(delivered.height);
|
|
773
|
+
if (!Number.isFinite(capturedWidth) || !Number.isFinite(capturedHeight) || capturedWidth <= 0 || capturedHeight <= 0) return undefined;
|
|
774
|
+
if (!Number.isFinite(deliveredWidth) || !Number.isFinite(deliveredHeight) || deliveredWidth <= 0 || deliveredHeight <= 0) return undefined;
|
|
775
|
+
const closeToOne = (ratio) => Math.abs(ratio - 1) < 0.002;
|
|
776
|
+
const x = deliveredWidth / capturedWidth;
|
|
777
|
+
const y = deliveredHeight / capturedHeight;
|
|
778
|
+
return { x: closeToOne(x) ? 1 : x, y: closeToOne(y) ? 1 : y };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** True when the delivered frame is exactly the capture. */
|
|
782
|
+
function scaleIsOne(scale) {
|
|
783
|
+
return scale === undefined || (scale.x === 1 && scale.y === 1);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const axesAgree = (scale) => Math.abs(scale.x - scale.y) <= 0.005 * Math.max(scale.x, scale.y);
|
|
787
|
+
const printed = (value) => Number(value.toFixed(4));
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* One line stating what the delivered frame is worth, in the shape the model can
|
|
791
|
+
* act on: the ratio, and the multiplier to apply to a reading.
|
|
792
|
+
* @param delivered - the attached image value.
|
|
793
|
+
* @param scale - per-axis delivered/captured ratios.
|
|
794
|
+
* @returns the sentence for the envelope.
|
|
795
|
+
*/
|
|
796
|
+
function deliveredScaleNote(delivered, scale) {
|
|
797
|
+
if (axesAgree(scale)) {
|
|
798
|
+
const ratio = (scale.x + scale.y) / 2;
|
|
799
|
+
return `delivered ${delivered.width}x${delivered.height} = ${printed(ratio)}x capture; multiply image readings by ${printed(1 / ratio)} to get screen pixels`;
|
|
800
|
+
}
|
|
801
|
+
return `delivered ${delivered.width}x${delivered.height} = ${printed(scale.x)}x horizontally, ${printed(scale.y)}x vertically of the capture; multiply image readings by ${printed(1 / scale.x)}, ${printed(1 / scale.y)} to get screen pixels`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Render one control outcome. */
|
|
805
|
+
function controlText(value) {
|
|
806
|
+
const cursor = value.cursor === null || value.cursor === undefined ? 'unknown' : `${value.cursor.x},${value.cursor.y}`;
|
|
807
|
+
const lines = [
|
|
808
|
+
'<desktop_action>',
|
|
809
|
+
`<action>${value.action}</action>`,
|
|
810
|
+
`<cursor>${cursor}</cursor>`,
|
|
811
|
+
`<foreground>${value.foregroundTitle ?? 'unknown'}${value.foregroundProcess === undefined || value.foregroundProcess === null ? '' : ` (${value.foregroundProcess})`}</foreground>`
|
|
812
|
+
];
|
|
813
|
+
if (Array.isArray(value.notes)) for (const note of value.notes) lines.push(`<note>${note}</note>`);
|
|
814
|
+
if (typeof value.capturePath === 'string') lines.push(`<capture_file>${value.capturePath}</capture_file>`);
|
|
815
|
+
if (typeof value.captureError === 'string') lines.push(`<capture_error>${value.captureError}</capture_error>`);
|
|
816
|
+
lines.push('</desktop_action>');
|
|
817
|
+
if (value.capture !== null && value.capture !== undefined) lines.push(coordinateContract(value.capture));
|
|
818
|
+
return lines.join('\n');
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const IMAGE_VALUE_SCHEMA = {
|
|
822
|
+
type: 'object',
|
|
823
|
+
additionalProperties: false,
|
|
824
|
+
properties: {
|
|
825
|
+
attachmentId: { type: 'string', required: true },
|
|
826
|
+
mediaType: { type: 'string', required: true },
|
|
827
|
+
bytes: { type: 'integer', required: true },
|
|
828
|
+
width: { type: 'integer', required: true },
|
|
829
|
+
height: { type: 'integer', required: true },
|
|
830
|
+
name: { type: 'string' },
|
|
831
|
+
originalDimensions: {
|
|
832
|
+
type: 'object',
|
|
833
|
+
additionalProperties: false,
|
|
834
|
+
properties: {
|
|
835
|
+
width: { type: 'integer', required: true },
|
|
836
|
+
height: { type: 'integer', required: true }
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const SCREEN_INFO_SCHEMA = {
|
|
843
|
+
type: 'object',
|
|
844
|
+
additionalProperties: false,
|
|
845
|
+
properties: {
|
|
846
|
+
device: { type: 'string', required: true },
|
|
847
|
+
primary: { type: 'boolean', required: true },
|
|
848
|
+
bounds: { type: 'string', required: true }
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
const WINDOW_INFO_SCHEMA = {
|
|
853
|
+
type: 'object',
|
|
854
|
+
additionalProperties: false,
|
|
855
|
+
properties: {
|
|
856
|
+
handle: { type: 'string' },
|
|
857
|
+
title: { type: 'string' },
|
|
858
|
+
class: { type: 'string' },
|
|
859
|
+
process: { type: 'string' },
|
|
860
|
+
pid: { type: 'integer' },
|
|
861
|
+
bounds: { type: 'string' },
|
|
862
|
+
visible: { type: 'boolean' },
|
|
863
|
+
minimized: { type: 'boolean' },
|
|
864
|
+
foreground: { type: 'boolean' }
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Copy the screen-inventory entry field by field. The sensor's own bookkeeping
|
|
870
|
+
* keys must never reach a closed output schema: a result carrying an undeclared
|
|
871
|
+
* key is rejected by the runtime *after* the side effect has already happened.
|
|
872
|
+
* @param screen - one entry from the sensor's `screens` array.
|
|
873
|
+
* @returns the declared projection.
|
|
874
|
+
*/
|
|
875
|
+
function screenEntryOf(screen) {
|
|
876
|
+
return { device: screen.device, primary: screen.primary, bounds: screen.bounds };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Drop absent-valued properties, recursively: `undefined` anywhere, and `null` as
|
|
881
|
+
* an object property.
|
|
882
|
+
*
|
|
883
|
+
* The runtime validates a tool result as lossless JSON and against the declared
|
|
884
|
+
* output schema, and when either fails it refuses the whole call *after* the body
|
|
885
|
+
* has already run against the real desktop. One property is enough, and this is
|
|
886
|
+
* one mistake wearing five hats: a window entry carrying `visible`, a projection
|
|
887
|
+
* emitting `{ path: undefined }`, a window entry carrying `class` when the
|
|
888
|
+
* enumeration script never reported one, and `foregroundProcess: null` — the
|
|
889
|
+
* process-name helper returns `$null` when it cannot resolve one, and the
|
|
890
|
+
* validator rejects null for a declared `string` exactly as it rejects undefined.
|
|
891
|
+
* Projections are written by hand and will keep forgetting, so results are pruned
|
|
892
|
+
* once at the boundary rather than trusted field by field.
|
|
893
|
+
*
|
|
894
|
+
* A `null` *array item* is kept: JSON itself uses null for a hole, so it is
|
|
895
|
+
* lossless, and dropping it would renumber the array.
|
|
896
|
+
* @param value - any JSON-shaped value.
|
|
897
|
+
* @returns the same shape, with absent-valued object properties removed.
|
|
898
|
+
*/
|
|
899
|
+
function pruneAbsent(value) {
|
|
900
|
+
// An array hole is not JSON either: the runtime walks array items the same way it
|
|
901
|
+
// walks properties, so `[1, undefined]` is refused. `null` is what JSON itself
|
|
902
|
+
// substitutes for a hole, so that is what the hole becomes here.
|
|
903
|
+
if (Array.isArray(value)) return value.map((entry) => (entry === undefined ? null : pruneAbsent(entry)));
|
|
904
|
+
if (value === null || typeof value !== 'object') return value;
|
|
905
|
+
const pruned = {};
|
|
906
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
907
|
+
if (entry === undefined || entry === null) continue;
|
|
908
|
+
pruned[key] = pruneAbsent(entry);
|
|
909
|
+
}
|
|
910
|
+
return pruned;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Copy a window entry field by field, dropping the sensor's undeclared keys.
|
|
915
|
+
*
|
|
916
|
+
* Every field a window entry may carry has to be listed here *and* in
|
|
917
|
+
* WINDOW_INFO_SCHEMA: the schema is closed, so a field the sensor reports but
|
|
918
|
+
* neither declares nor projects is rejected by the runtime after the action has
|
|
919
|
+
* already happened. `visible` shipped that way — the sensor reports it, the
|
|
920
|
+
* schema did not declare it, and the `windows` action was refused outright.
|
|
921
|
+
*
|
|
922
|
+
* A field the sensor does *not* report is the mirror image of the same mistake:
|
|
923
|
+
* `class` is reported by the capture path but not by the window enumeration, so
|
|
924
|
+
* writing it unconditionally put `class: undefined` into every listed entry. Each
|
|
925
|
+
* optional field is therefore spread in only when it has a value.
|
|
926
|
+
* @param window - a sensor window entry, or undefined.
|
|
927
|
+
* @returns the declared projection, or undefined when there was no window.
|
|
928
|
+
*/
|
|
929
|
+
function windowInfoOf(window) {
|
|
930
|
+
if (window === null || window === undefined) return undefined;
|
|
931
|
+
return {
|
|
932
|
+
handle: window.handle,
|
|
933
|
+
title: window.title,
|
|
934
|
+
...(window.class === undefined ? {} : { class: window.class }),
|
|
935
|
+
process: window.process,
|
|
936
|
+
pid: window.pid,
|
|
937
|
+
bounds: window.bounds,
|
|
938
|
+
...(window.visible === undefined ? {} : { visible: window.visible }),
|
|
939
|
+
...(window.minimized === undefined ? {} : { minimized: window.minimized }),
|
|
940
|
+
...(window.foreground === undefined ? {} : { foreground: window.foreground })
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const CAPTURE_SCHEMA = {
|
|
945
|
+
type: 'object',
|
|
946
|
+
additionalProperties: false,
|
|
947
|
+
properties: {
|
|
948
|
+
path: { type: 'string', required: true },
|
|
949
|
+
kind: { type: 'string', required: true },
|
|
950
|
+
imageWidth: { type: 'integer', required: true },
|
|
951
|
+
imageHeight: { type: 'integer', required: true },
|
|
952
|
+
originX: { type: 'integer', required: true },
|
|
953
|
+
originY: { type: 'integer', required: true },
|
|
954
|
+
scale: { type: 'number', required: true },
|
|
955
|
+
screens: { type: 'array', required: true, items: SCREEN_INFO_SCHEMA },
|
|
956
|
+
cursor: {
|
|
957
|
+
type: 'object',
|
|
958
|
+
additionalProperties: false,
|
|
959
|
+
properties: {
|
|
960
|
+
x: { type: 'integer', required: true },
|
|
961
|
+
y: { type: 'integer', required: true },
|
|
962
|
+
screen: { type: 'string' }
|
|
963
|
+
}
|
|
964
|
+
},
|
|
965
|
+
foregroundWindow: WINDOW_INFO_SCHEMA,
|
|
966
|
+
image: IMAGE_VALUE_SCHEMA,
|
|
967
|
+
imageError: { type: 'string' },
|
|
968
|
+
imageSkipped: { type: 'string' },
|
|
969
|
+
frameHash: { type: 'string' },
|
|
970
|
+
unchanged: { type: 'boolean' },
|
|
971
|
+
rulers: { type: 'boolean' },
|
|
972
|
+
capturedAt: { type: 'string' }
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
/** Cursor projection shared by the capture and control schemas. */
|
|
977
|
+
const CURSOR_SCHEMA = {
|
|
978
|
+
type: 'object',
|
|
979
|
+
additionalProperties: false,
|
|
980
|
+
properties: {
|
|
981
|
+
x: { type: 'integer', required: true },
|
|
982
|
+
y: { type: 'integer', required: true },
|
|
983
|
+
screen: { type: 'string' }
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
/** Actions a sequence step may name. `wait` is served in this process. */
|
|
988
|
+
const SEQUENCE_ACTIONS = ['click', 'doubleClick', 'rightClick', 'middleClick', 'move', 'drag', 'scroll', 'type', 'key', 'focus', 'wait'];
|
|
989
|
+
/** Actions whose x/y are mandatory. */
|
|
990
|
+
const POINTER_ACTIONS = new Set(['click', 'doubleClick', 'rightClick', 'middleClick', 'move', 'scroll']);
|
|
991
|
+
/** The risk ladder the batch gate enforces. */
|
|
992
|
+
const RISKS = ['low', 'medium', 'high'];
|
|
993
|
+
const RISK_ORDER = { low: 0, medium: 1, high: 2 };
|
|
994
|
+
/** Step fields forwarded verbatim to the action helper. */
|
|
995
|
+
const STEP_FIELDS = ['x', 'y', 'toX', 'toY', 'amount', 'text', 'key', 'title', 'button'];
|
|
996
|
+
|
|
997
|
+
const SEQUENCE_STEP_RESULT_SCHEMA = {
|
|
998
|
+
type: 'object',
|
|
999
|
+
additionalProperties: false,
|
|
1000
|
+
properties: {
|
|
1001
|
+
index: { type: 'integer', required: true },
|
|
1002
|
+
action: { type: 'string', required: true },
|
|
1003
|
+
ok: { type: 'boolean', required: true },
|
|
1004
|
+
ms: { type: 'integer', required: true },
|
|
1005
|
+
cursor: CURSOR_SCHEMA,
|
|
1006
|
+
foregroundTitle: { type: 'string' },
|
|
1007
|
+
note: { type: 'string' },
|
|
1008
|
+
risk: { type: 'string' }
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
const SEQUENCE_SCHEMA = {
|
|
1013
|
+
type: 'object',
|
|
1014
|
+
additionalProperties: false,
|
|
1015
|
+
properties: {
|
|
1016
|
+
risk: { type: 'string', required: true },
|
|
1017
|
+
confirmed: { type: 'boolean', required: true },
|
|
1018
|
+
planned: { type: 'boolean' },
|
|
1019
|
+
stepCount: { type: 'integer', required: true },
|
|
1020
|
+
executed: { type: 'integer', required: true },
|
|
1021
|
+
failed: { type: 'integer', required: true },
|
|
1022
|
+
steps: { type: 'array', required: true, items: SEQUENCE_STEP_RESULT_SCHEMA },
|
|
1023
|
+
cursor: CURSOR_SCHEMA,
|
|
1024
|
+
foregroundTitle: { type: 'string' },
|
|
1025
|
+
foregroundProcess: { type: 'string' },
|
|
1026
|
+
capture: CAPTURE_SCHEMA,
|
|
1027
|
+
capturePath: { type: 'string' },
|
|
1028
|
+
captureError: { type: 'string' },
|
|
1029
|
+
frameHash: { type: 'string' },
|
|
1030
|
+
unchanged: { type: 'boolean' },
|
|
1031
|
+
imageSkipped: { type: 'string' },
|
|
1032
|
+
notes: { type: 'array', items: { type: 'string' } },
|
|
1033
|
+
actedAt: { type: 'string' }
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Validate and normalize one batch of steps. A step is a desktop_control action
|
|
1039
|
+
* without its own screenshot, plus optional per-step `settleMs`, `risk` and
|
|
1040
|
+
* `riskNote`; the `wait` action takes `ms` instead.
|
|
1041
|
+
*
|
|
1042
|
+
* This runs before anything touches the desktop, so a malformed batch costs one
|
|
1043
|
+
* round trip instead of half a gesture.
|
|
1044
|
+
*
|
|
1045
|
+
* @param args - the tool arguments.
|
|
1046
|
+
* @param config - validated plugin configuration (for the step cap).
|
|
1047
|
+
* @returns the normalized steps.
|
|
1048
|
+
*/
|
|
1049
|
+
function normalizeSequence(args, config) {
|
|
1050
|
+
const raw = Array.isArray(args?.steps) ? args.steps : [];
|
|
1051
|
+
if (raw.length === 0) throw new Error('desktop_sequence needs at least one step');
|
|
1052
|
+
if (raw.length > config.maxSequenceSteps) {
|
|
1053
|
+
throw new Error(`desktop_sequence accepts at most ${config.maxSequenceSteps} steps (got ${raw.length}); split the batch or raise maxSequenceSteps`);
|
|
1054
|
+
}
|
|
1055
|
+
return raw.map((step, offset) => {
|
|
1056
|
+
const position = offset + 1;
|
|
1057
|
+
if (step === null || typeof step !== 'object' || Array.isArray(step)) throw new Error(`step ${position} must be an object`);
|
|
1058
|
+
const action = step.action;
|
|
1059
|
+
if (typeof action !== 'string' || !SEQUENCE_ACTIONS.includes(action)) {
|
|
1060
|
+
throw new Error(`step ${position} has an unknown action ${JSON.stringify(action)}; use ${SEQUENCE_ACTIONS.join('/')}`);
|
|
1061
|
+
}
|
|
1062
|
+
const normalized = { action };
|
|
1063
|
+
for (const field of [...STEP_FIELDS, 'ms', 'risk', 'riskNote']) {
|
|
1064
|
+
if (step[field] !== undefined && step[field] !== null) normalized[field] = step[field];
|
|
1065
|
+
}
|
|
1066
|
+
if (POINTER_ACTIONS.has(action) && (!Number.isInteger(normalized.x) || !Number.isInteger(normalized.y))) {
|
|
1067
|
+
throw new Error(`step ${position} (${action}) needs integer x and y`);
|
|
1068
|
+
}
|
|
1069
|
+
if (action === 'drag' && (!Number.isInteger(normalized.toX) || !Number.isInteger(normalized.toY))) {
|
|
1070
|
+
throw new Error(`step ${position} (drag) needs integer toX and toY`);
|
|
1071
|
+
}
|
|
1072
|
+
if (action === 'type' && typeof normalized.text !== 'string') throw new Error(`step ${position} (type) needs text`);
|
|
1073
|
+
if (action === 'key' && typeof normalized.key !== 'string') throw new Error(`step ${position} (key) needs key`);
|
|
1074
|
+
if (action === 'focus' && typeof normalized.title !== 'string') throw new Error(`step ${position} (focus) needs title`);
|
|
1075
|
+
if (action === 'wait' && !Number.isInteger(normalized.ms)) throw new Error(`step ${position} (wait) needs integer ms`);
|
|
1076
|
+
if (normalized.risk !== undefined && !RISKS.includes(normalized.risk)) {
|
|
1077
|
+
throw new Error(`step ${position} risk must be one of ${RISKS.join('/')}`);
|
|
1078
|
+
}
|
|
1079
|
+
return normalized;
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Apply the risk gate: the model declares risk, the tool enforces it. Low risk
|
|
1085
|
+
* batches freely; medium/high is refused before anything is executed unless the
|
|
1086
|
+
* caller passes `confirm: true` (which it does only after the user agreed).
|
|
1087
|
+
*
|
|
1088
|
+
* @param steps - normalized steps.
|
|
1089
|
+
* @param declared - the batch-level risk the model declared.
|
|
1090
|
+
* @param confirm - the caller's explicit approval flag.
|
|
1091
|
+
* @returns the effective risk level.
|
|
1092
|
+
*/
|
|
1093
|
+
function sequenceRisk(steps, declared, confirm) {
|
|
1094
|
+
let effective = RISKS.includes(declared) ? declared : 'low';
|
|
1095
|
+
const flagged = [];
|
|
1096
|
+
for (const [offset, step] of steps.entries()) {
|
|
1097
|
+
const stepRisk = step.risk ?? 'low';
|
|
1098
|
+
if (RISK_ORDER[stepRisk] > 0) {
|
|
1099
|
+
const why = typeof step.riskNote === 'string' && step.riskNote.length > 0 ? ` (${step.riskNote})` : '';
|
|
1100
|
+
flagged.push(`#${offset + 1} ${step.action}${why}`);
|
|
1101
|
+
}
|
|
1102
|
+
if (RISK_ORDER[stepRisk] > RISK_ORDER[effective]) effective = stepRisk;
|
|
1103
|
+
}
|
|
1104
|
+
if (RISK_ORDER[effective] > 0 && confirm !== true) {
|
|
1105
|
+
const parts = flagged.length > 0 ? flagged.join(', ') : 'the batch as a whole';
|
|
1106
|
+
throw new Error(
|
|
1107
|
+
`desktop_sequence refused before touching the desktop: this batch is declared ${effective} risk (${parts}). ` +
|
|
1108
|
+
'Show the user what it will do, then re-issue the same steps with confirm: true — or run the risky step on its own so it can be reviewed alone.'
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
return effective;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** A wait that still honours an interrupted turn. */
|
|
1115
|
+
function abortableWait(ms, signal) {
|
|
1116
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
1117
|
+
const timer = setTimeout(resolvePromise, Math.max(0, ms));
|
|
1118
|
+
signal?.addEventListener('abort', () => {
|
|
1119
|
+
clearTimeout(timer);
|
|
1120
|
+
rejectPromise(new Error('pilot: the batch was cancelled during a wait'));
|
|
1121
|
+
}, { once: true });
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** Run one step: `wait` locally, everything else through the action helper. */
|
|
1126
|
+
async function runSequenceStep(step, config, options) {
|
|
1127
|
+
const started = Date.now();
|
|
1128
|
+
if (step.action === 'wait') {
|
|
1129
|
+
await abortableWait(step.ms, options.signal);
|
|
1130
|
+
return { action: 'wait', ok: true, ms: Date.now() - started };
|
|
1131
|
+
}
|
|
1132
|
+
const params = { action: step.action, settleMs: Number.isSafeInteger(step.settleMs) ? step.settleMs : config.stepSettleMs };
|
|
1133
|
+
for (const field of STEP_FIELDS) if (step[field] !== undefined) params[field] = step[field];
|
|
1134
|
+
const acted = await runAction(params, config, options);
|
|
1135
|
+
const entry = { action: step.action, ok: true, ms: Date.now() - started };
|
|
1136
|
+
if (acted.cursor !== undefined) entry.cursor = acted.cursor;
|
|
1137
|
+
if (acted.foregroundTitle !== undefined) entry.foregroundTitle = acted.foregroundTitle;
|
|
1138
|
+
if (Array.isArray(acted.notes) && acted.notes.length > 0) entry.note = acted.notes.join('; ');
|
|
1139
|
+
return entry;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** Content hash of one saved frame: identifies a screen that did not change. */
|
|
1143
|
+
function frameHashOf(file) {
|
|
1144
|
+
try {
|
|
1145
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex').slice(0, 16);
|
|
1146
|
+
} catch {
|
|
1147
|
+
return undefined;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Last frame hash per session, so an identical frame can be recognised. */
|
|
1152
|
+
const lastFrameHashes = new Map();
|
|
1153
|
+
|
|
1154
|
+
/** Render one batch outcome: a compact per-step ledger, then the end frame. */
|
|
1155
|
+
function sequenceText(value) {
|
|
1156
|
+
const flags = `${value.risk}${value.confirmed === true ? ', confirmed' : ''}${value.planned === true ? ', dry run' : ''}`;
|
|
1157
|
+
const lines = [
|
|
1158
|
+
'<desktop_sequence>',
|
|
1159
|
+
`<risk>${flags}</risk>`,
|
|
1160
|
+
`<steps>${value.executed}/${value.stepCount} executed${value.failed > 0 ? `, ${value.failed} failed` : ''}</steps>`
|
|
1161
|
+
];
|
|
1162
|
+
for (const step of value.steps ?? []) {
|
|
1163
|
+
lines.push(`<step n="${step.index}" action="${step.action}" ok="${step.ok}" ms="${step.ms}">${step.note === undefined ? '' : step.note}</step>`);
|
|
1164
|
+
}
|
|
1165
|
+
if (value.foregroundTitle !== undefined) {
|
|
1166
|
+
lines.push(`<foreground>${value.foregroundTitle}${value.foregroundProcess === undefined || value.foregroundProcess === null ? '' : ` (${value.foregroundProcess})`}</foreground>`);
|
|
1167
|
+
}
|
|
1168
|
+
if (value.cursor !== undefined && value.cursor !== null) lines.push(`<cursor>${value.cursor.x},${value.cursor.y}</cursor>`);
|
|
1169
|
+
if (typeof value.capturePath === 'string') lines.push(`<capture_file>${value.capturePath}</capture_file>`);
|
|
1170
|
+
if (typeof value.frameHash === 'string') lines.push(`<frame_hash>${value.frameHash}${value.unchanged === true ? ' (unchanged)' : ''}</frame_hash>`);
|
|
1171
|
+
if (typeof value.imageSkipped === 'string') lines.push(`<image_skipped>${value.imageSkipped}</image_skipped>`);
|
|
1172
|
+
if (typeof value.captureError === 'string') lines.push(`<capture_error>${value.captureError}</capture_error>`);
|
|
1173
|
+
for (const note of value.notes ?? []) lines.push(`<note>${note}</note>`);
|
|
1174
|
+
lines.push('</desktop_sequence>');
|
|
1175
|
+
if (value.capture !== undefined && value.capture !== null) lines.push(coordinateContract(value.capture));
|
|
1176
|
+
const blocks = [{ type: 'text', text: lines.join('\n') }];
|
|
1177
|
+
const image = value.capture === undefined || value.capture === null ? undefined : value.capture.image;
|
|
1178
|
+
if (image !== undefined) blocks.push({ type: 'image', attachment: image });
|
|
1179
|
+
return blocks;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Register `screen_view`, `desktop_control` and `desktop_sequence`.
|
|
1184
|
+
* @param ctx - the plugin context owning the tool registry.
|
|
1185
|
+
* @param config - validated plugin configuration.
|
|
1186
|
+
*/
|
|
1187
|
+
function apply(ctx, config) {
|
|
1188
|
+
for (const key of ['captureRetention', 'timeoutMs', 'settleMs', 'stepSettleMs', 'maxSequenceSteps']) {
|
|
1189
|
+
const value = config[key];
|
|
1190
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) throw new Error(`pilot: config ${key} must be a positive number`);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
ctx.tools.register(defineTool({
|
|
1194
|
+
name: 'screen_view',
|
|
1195
|
+
description: [
|
|
1196
|
+
'See the live desktop: capture the screen and return the actual image, so you can read what is on it instead of guessing.',
|
|
1197
|
+
'Use this before any desktop_control action, and again after acting, to confirm the result.',
|
|
1198
|
+
'The frame is per-monitor DPI aware: one image pixel equals one real screen pixel, so coordinates you read off the image are exactly the x/y that desktop_control consumes.',
|
|
1199
|
+
'Defaults to the primary monitor; pass window to frame one app, screen "all" to composite every monitor, or region for a crop.',
|
|
1200
|
+
'Requires the current model to accept image input.'
|
|
1201
|
+
].join(' '),
|
|
1202
|
+
parameters: {
|
|
1203
|
+
screen: {
|
|
1204
|
+
type: 'string',
|
|
1205
|
+
description: "Which monitor: 'primary' (default), 'all' for the whole virtual desktop, or a 0-based monitor index."
|
|
1206
|
+
},
|
|
1207
|
+
window: {
|
|
1208
|
+
type: 'string',
|
|
1209
|
+
description: 'Case-insensitive substring of a window title or process name; frames that window instead of a monitor.'
|
|
1210
|
+
},
|
|
1211
|
+
region: {
|
|
1212
|
+
type: 'string',
|
|
1213
|
+
description: "Crop in physical screen pixels as 'x,y,width,height', relative to the captured area's top-left."
|
|
1214
|
+
},
|
|
1215
|
+
includeCursor: {
|
|
1216
|
+
type: 'boolean',
|
|
1217
|
+
description: 'Draw a crosshair at the pointer (default true) so you can see where it is.'
|
|
1218
|
+
},
|
|
1219
|
+
rulers: {
|
|
1220
|
+
type: 'boolean',
|
|
1221
|
+
description: 'Draw coordinate rulers on the frame — ticks every 200 px labelled with screen coordinates — so a position can be read without counting pixels (default: the rulers setting).'
|
|
1222
|
+
},
|
|
1223
|
+
forceImage: {
|
|
1224
|
+
type: 'boolean',
|
|
1225
|
+
description: 'Attach the frame even when it is pixel-identical to the previous frame of this session (default: such a duplicate is reported as unchanged and not attached).'
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
output: {
|
|
1229
|
+
schema: CAPTURE_SCHEMA,
|
|
1230
|
+
render: (_args, value) => captureContent(value),
|
|
1231
|
+
presentationMeta: (_args, value) => ({ path: value.path })
|
|
1232
|
+
},
|
|
1233
|
+
isConcurrencySafe: () => true,
|
|
1234
|
+
async execute(args, exec) {
|
|
1235
|
+
warmWorker(config);
|
|
1236
|
+
await assertImageCapableRoute(ctx, exec, 'screen_view');
|
|
1237
|
+
const value = await capture(ctx, exec, config, { ...args, forceImage: args.forceImage === true });
|
|
1238
|
+
if (value.image === undefined && value.imageSkipped === undefined) {
|
|
1239
|
+
throw new Error(`screen_view could not attach the frame${value.imageError === undefined ? '' : `: ${value.imageError}`}`);
|
|
1240
|
+
}
|
|
1241
|
+
return pruneAbsent(value);
|
|
1242
|
+
},
|
|
1243
|
+
presentCall(args) {
|
|
1244
|
+
return {
|
|
1245
|
+
card: 'generic',
|
|
1246
|
+
title: args.window === undefined ? 'View screen' : `View window ${args.window}`,
|
|
1247
|
+
kind: 'read'
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
}));
|
|
1251
|
+
|
|
1252
|
+
ctx.tools.register(defineTool({
|
|
1253
|
+
name: 'desktop_control',
|
|
1254
|
+
description: [
|
|
1255
|
+
'Operate the live desktop: move the pointer, click, drag, scroll, type text, send keys, focus a window, or list windows.',
|
|
1256
|
+
'Coordinates are physical screen pixels and match screen_view images exactly, so read the target off a fresh screenshot and reuse those pixels verbatim.',
|
|
1257
|
+
'Each press, keystroke, and window focus goes to whatever is focused on the real desktop, so call screen_view first and re-check after acting when the result matters.',
|
|
1258
|
+
'Set capture true to receive a fresh image in the same result, which shows the effect of the action without a second round trip.',
|
|
1259
|
+
'Text arrives as a single paste, so applications see one insertion rather than per-character typing.'
|
|
1260
|
+
].join(' '),
|
|
1261
|
+
parameters: {
|
|
1262
|
+
action: {
|
|
1263
|
+
type: 'string',
|
|
1264
|
+
required: true,
|
|
1265
|
+
enum: ['click', 'doubleClick', 'rightClick', 'middleClick', 'move', 'drag', 'scroll', 'type', 'key', 'focus', 'windows'],
|
|
1266
|
+
description: 'click | doubleClick | rightClick | middleClick | move | drag | scroll | type | key | focus | windows.'
|
|
1267
|
+
},
|
|
1268
|
+
x: { type: 'integer', description: 'Target x in physical screen pixels; required for the pointer actions and for scroll-at-a-point.' },
|
|
1269
|
+
y: { type: 'integer', description: 'Target y in physical screen pixels; required for the pointer actions and for scroll-at-a-point.' },
|
|
1270
|
+
toX: { type: 'integer', description: 'drag end x, or a nonzero value to make scroll horizontal.' },
|
|
1271
|
+
toY: { type: 'integer', description: 'drag end y, or a nonzero value to make scroll horizontal.' },
|
|
1272
|
+
text: { type: 'string', description: 'Text to insert for the type action.' },
|
|
1273
|
+
key: { type: 'string', description: "Key for the key action: a single character, 'enter', 'tab', 'esc', 'f5', or a chord such as 'ctrl+s' or 'alt+tab'." },
|
|
1274
|
+
amount: { type: 'integer', description: 'Wheel notches for scroll; positive scrolls up, negative down. Default 3.' },
|
|
1275
|
+
button: {
|
|
1276
|
+
type: 'string',
|
|
1277
|
+
enum: ['left', 'right', 'middle'],
|
|
1278
|
+
description: 'Mouse button for click, doubleClick, and drag. Default left.'
|
|
1279
|
+
},
|
|
1280
|
+
title: {
|
|
1281
|
+
type: 'string',
|
|
1282
|
+
description: 'Case-insensitive window title or process substring; required by focus, and filters windows.'
|
|
1283
|
+
},
|
|
1284
|
+
capture: {
|
|
1285
|
+
type: 'boolean',
|
|
1286
|
+
description: 'Capture a fresh frame after the action and show it in this result. Default true.'
|
|
1287
|
+
},
|
|
1288
|
+
settleMs: {
|
|
1289
|
+
type: 'integer',
|
|
1290
|
+
description: 'Milliseconds to wait before the automatic capture, so the screen can repaint. Default 750.'
|
|
1291
|
+
},
|
|
1292
|
+
forceImage: {
|
|
1293
|
+
type: 'boolean',
|
|
1294
|
+
description: 'Attach the frame even when the screen is pixel-identical to the previous one (default: such a duplicate is not attached again).'
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
output: {
|
|
1298
|
+
schema: {
|
|
1299
|
+
type: 'object',
|
|
1300
|
+
additionalProperties: false,
|
|
1301
|
+
properties: {
|
|
1302
|
+
action: { type: 'string', required: true },
|
|
1303
|
+
cursor: {
|
|
1304
|
+
type: 'object',
|
|
1305
|
+
additionalProperties: false,
|
|
1306
|
+
properties: {
|
|
1307
|
+
x: { type: 'integer', required: true },
|
|
1308
|
+
y: { type: 'integer', required: true },
|
|
1309
|
+
screen: { type: 'string' }
|
|
1310
|
+
}
|
|
1311
|
+
},
|
|
1312
|
+
foregroundTitle: { type: 'string' },
|
|
1313
|
+
foregroundClass: { type: 'string' },
|
|
1314
|
+
foregroundProcess: { type: 'string' },
|
|
1315
|
+
notes: { type: 'array', items: { type: 'string' } },
|
|
1316
|
+
windows: { type: 'array', items: WINDOW_INFO_SCHEMA },
|
|
1317
|
+
windowCount: { type: 'integer' },
|
|
1318
|
+
actedAt: { type: 'string' },
|
|
1319
|
+
capture: CAPTURE_SCHEMA,
|
|
1320
|
+
capturePath: { type: 'string' },
|
|
1321
|
+
captureError: { type: 'string' },
|
|
1322
|
+
frameHash: { type: 'string' },
|
|
1323
|
+
unchanged: { type: 'boolean' },
|
|
1324
|
+
imageSkipped: { type: 'string' }
|
|
1325
|
+
}
|
|
1326
|
+
},
|
|
1327
|
+
render: (_args, value) => {
|
|
1328
|
+
const blocks = [{ type: 'text', text: controlText(value) }];
|
|
1329
|
+
if (value.capture !== null && value.capture !== undefined && value.capture.image !== undefined) {
|
|
1330
|
+
blocks.push({ type: 'image', attachment: value.capture.image });
|
|
1331
|
+
}
|
|
1332
|
+
return blocks;
|
|
1333
|
+
}
|
|
1334
|
+
},
|
|
1335
|
+
isConcurrencySafe: () => false,
|
|
1336
|
+
async execute(args, exec) {
|
|
1337
|
+
warmWorker(config);
|
|
1338
|
+
const wantsCapture = args.capture !== false;
|
|
1339
|
+
if (wantsCapture) await assertImageCapableRoute(ctx, exec, 'desktop_control with capture');
|
|
1340
|
+
|
|
1341
|
+
const settle = Number.isSafeInteger(args.settleMs) ? args.settleMs : config.settleMs;
|
|
1342
|
+
const params = { action: args.action, settleMs: settle };
|
|
1343
|
+
for (const field of ['x', 'y', 'toX', 'toY', 'amount', 'text', 'key', 'title', 'button']) {
|
|
1344
|
+
if (args[field] !== undefined && args[field] !== null) params[field] = args[field];
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const options = optionsFor(exec, config);
|
|
1348
|
+
const acted = await runAction(params, config, options);
|
|
1349
|
+
// Copy the declared fields only: the helper's own bookkeeping keys
|
|
1350
|
+
// must not reach the closed output schema.
|
|
1351
|
+
const value = { action: args.action };
|
|
1352
|
+
for (const field of ['cursor', 'foregroundTitle', 'foregroundClass', 'foregroundProcess', 'notes', 'windowCount', 'actedAt']) {
|
|
1353
|
+
if (acted[field] !== undefined) value[field] = acted[field];
|
|
1354
|
+
}
|
|
1355
|
+
// The list is projected, not copied: the sensor's window entries carry
|
|
1356
|
+
// fields of their own (`visible`, bookkeeping) and this schema is closed.
|
|
1357
|
+
if (Array.isArray(acted.windows)) {
|
|
1358
|
+
value.windows = acted.windows.map((entry) => windowInfoOf(entry)).filter((entry) => entry !== undefined);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (wantsCapture) {
|
|
1362
|
+
try {
|
|
1363
|
+
const frame = await capture(ctx, exec, config, { includeCursor: true, forceImage: args.forceImage === true });
|
|
1364
|
+
value.capture = frame;
|
|
1365
|
+
value.capturePath = frame.path;
|
|
1366
|
+
if (frame.frameHash !== undefined) value.frameHash = frame.frameHash;
|
|
1367
|
+
if (frame.unchanged !== undefined) value.unchanged = frame.unchanged;
|
|
1368
|
+
if (frame.imageSkipped !== undefined) value.imageSkipped = frame.imageSkipped;
|
|
1369
|
+
if (frame.image === undefined && frame.imageError !== undefined) value.captureError = frame.imageError;
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
value.captureError = error instanceof Error ? error.message : String(error);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return pruneAbsent(value);
|
|
1375
|
+
},
|
|
1376
|
+
presentCall(args) {
|
|
1377
|
+
return {
|
|
1378
|
+
card: 'generic',
|
|
1379
|
+
title: `Desktop ${args.action}${args.title === undefined ? '' : ` ${args.title}`}`,
|
|
1380
|
+
kind: 'execute'
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
}));
|
|
1384
|
+
|
|
1385
|
+
ctx.tools.register(defineTool({
|
|
1386
|
+
name: 'desktop_sequence',
|
|
1387
|
+
description: [
|
|
1388
|
+
'Run several desktop actions in ONE call: focus, click, type, key, scroll … then look once at the end.',
|
|
1389
|
+
'This is the fast path. Calling desktop_control once per action costs one model round trip and one screenshot per action; a batch costs one round trip and, by default, a single frame — that difference, not the click speed, is what makes desktop work feel quick.',
|
|
1390
|
+
'Each step is a desktop_control action without its own screenshot, and steps run in order. By default the batch stops at the first failure, so a refused focus can never be followed by typing into the wrong window.',
|
|
1391
|
+
'After the last step the result carries one frame — by default of the window that was foreground during the batch, not the whole desktop — plus a frame_hash. When that hash matches the previous frame the image is not attached again; pass forceImage: true to see it anyway.',
|
|
1392
|
+
'Declare risk honestly: "low" (default) batches freely; a batch that is medium/high — at the batch level or on any step — is refused unless you pass confirm: true, which you do only after the user has agreed. dryRun: true validates the batch and lists the plan without touching the desktop.',
|
|
1393
|
+
'Batch the low-risk steps of a task and verify once at the end; only stop to screenshot in the middle when the UI state is genuinely uncertain.'
|
|
1394
|
+
].join(' '),
|
|
1395
|
+
parameters: {
|
|
1396
|
+
steps: {
|
|
1397
|
+
type: 'array',
|
|
1398
|
+
required: true,
|
|
1399
|
+
description: 'Ordered steps. Each object is one desktop_control action: {action, x, y, toX, toY, text, key, title, amount, button, settleMs, risk, riskNote}; action "wait" takes ms. Coordinates are physical screen pixels.'
|
|
1400
|
+
},
|
|
1401
|
+
risk: {
|
|
1402
|
+
type: 'string',
|
|
1403
|
+
enum: RISKS,
|
|
1404
|
+
description: 'Risk you declare for the whole batch (default low). medium/high requires confirm: true.'
|
|
1405
|
+
},
|
|
1406
|
+
confirm: {
|
|
1407
|
+
type: 'boolean',
|
|
1408
|
+
description: 'Explicit approval for a medium/high-risk batch. Without it the tool refuses before touching the desktop.'
|
|
1409
|
+
},
|
|
1410
|
+
dryRun: {
|
|
1411
|
+
type: 'boolean',
|
|
1412
|
+
description: 'Validate the batch and list the plan without executing anything.'
|
|
1413
|
+
},
|
|
1414
|
+
capture: {
|
|
1415
|
+
type: 'string',
|
|
1416
|
+
enum: ['end', 'none'],
|
|
1417
|
+
description: "end (default) captures one frame after the last step; none skips the frame entirely."
|
|
1418
|
+
},
|
|
1419
|
+
target: {
|
|
1420
|
+
type: 'string',
|
|
1421
|
+
enum: ['window', 'screen', 'all'],
|
|
1422
|
+
description: "What the end frame shows: window (default) = the window that was foreground during the batch, screen = the primary monitor, all = the whole virtual desktop."
|
|
1423
|
+
},
|
|
1424
|
+
window: {
|
|
1425
|
+
type: 'string',
|
|
1426
|
+
description: 'Frame this window instead (case-insensitive title or process substring), overriding target.'
|
|
1427
|
+
},
|
|
1428
|
+
region: {
|
|
1429
|
+
type: 'string',
|
|
1430
|
+
description: "Crop the end frame in physical pixels as 'x,y,width,height'."
|
|
1431
|
+
},
|
|
1432
|
+
forceImage: {
|
|
1433
|
+
type: 'boolean',
|
|
1434
|
+
description: 'Attach the end frame even when it is pixel-identical to the previous frame.'
|
|
1435
|
+
},
|
|
1436
|
+
stopOnError: {
|
|
1437
|
+
type: 'boolean',
|
|
1438
|
+
description: 'Stop at the first failing step (default true); false keeps going and reports every failure.'
|
|
1439
|
+
},
|
|
1440
|
+
settleMs: {
|
|
1441
|
+
type: 'integer',
|
|
1442
|
+
description: 'Delay before the end capture so the screen can repaint (default: the configured settleMs).'
|
|
1443
|
+
}
|
|
1444
|
+
},
|
|
1445
|
+
output: {
|
|
1446
|
+
schema: SEQUENCE_SCHEMA,
|
|
1447
|
+
render: (_args, value) => sequenceText(value),
|
|
1448
|
+
// `capturePath` is optional in SEQUENCE_SCHEMA: a dry run, capture:'none' and a
|
|
1449
|
+
// failed end capture all return without one. The projector must therefore omit
|
|
1450
|
+
// the key rather than emit `{ path: undefined }` — an undefined property is not
|
|
1451
|
+
// lossless JSON, and the runtime rejects the whole result *after* the batch ran.
|
|
1452
|
+
presentationMeta: (_args, value) => (typeof value.capturePath === 'string' ? { path: value.capturePath } : {})
|
|
1453
|
+
},
|
|
1454
|
+
isConcurrencySafe: () => false,
|
|
1455
|
+
async execute(args, exec) {
|
|
1456
|
+
warmWorker(config);
|
|
1457
|
+
const steps = normalizeSequence(args, config);
|
|
1458
|
+
const risk = sequenceRisk(steps, args.risk, args.confirm);
|
|
1459
|
+
const confirmed = args.confirm === true;
|
|
1460
|
+
|
|
1461
|
+
if (args.dryRun === true) {
|
|
1462
|
+
return pruneAbsent({
|
|
1463
|
+
risk,
|
|
1464
|
+
confirmed,
|
|
1465
|
+
planned: true,
|
|
1466
|
+
stepCount: steps.length,
|
|
1467
|
+
executed: 0,
|
|
1468
|
+
failed: 0,
|
|
1469
|
+
steps: steps.map((step, offset) => ({
|
|
1470
|
+
index: offset + 1,
|
|
1471
|
+
action: step.action,
|
|
1472
|
+
ok: true,
|
|
1473
|
+
ms: 0,
|
|
1474
|
+
...(step.risk === undefined ? {} : { risk: step.risk }),
|
|
1475
|
+
...(typeof step.riskNote === 'string' ? { note: step.riskNote } : {})
|
|
1476
|
+
})),
|
|
1477
|
+
notes: ['dry run: nothing was executed']
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const wantsFrame = args.capture !== 'none';
|
|
1482
|
+
if (wantsFrame) await assertImageCapableRoute(ctx, exec, 'desktop_sequence with capture');
|
|
1483
|
+
const options = optionsFor(exec, config);
|
|
1484
|
+
const stopOnError = args.stopOnError !== false;
|
|
1485
|
+
const results = [];
|
|
1486
|
+
let failed = 0;
|
|
1487
|
+
|
|
1488
|
+
for (const [offset, step] of steps.entries()) {
|
|
1489
|
+
if (exec?.signal?.aborted) throw new Error('desktop_sequence: the batch was cancelled');
|
|
1490
|
+
const stepStarted = Date.now();
|
|
1491
|
+
try {
|
|
1492
|
+
const entry = await runSequenceStep(step, config, options);
|
|
1493
|
+
results.push({ index: offset + 1, ...entry, ...(step.risk === undefined ? {} : { risk: step.risk }) });
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
failed += 1;
|
|
1496
|
+
results.push({
|
|
1497
|
+
index: offset + 1,
|
|
1498
|
+
action: step.action,
|
|
1499
|
+
ok: false,
|
|
1500
|
+
ms: Date.now() - stepStarted,
|
|
1501
|
+
note: error instanceof Error ? error.message : String(error),
|
|
1502
|
+
...(step.risk === undefined ? {} : { risk: step.risk })
|
|
1503
|
+
});
|
|
1504
|
+
if (stopOnError) break;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const value = {
|
|
1509
|
+
risk,
|
|
1510
|
+
confirmed,
|
|
1511
|
+
planned: false,
|
|
1512
|
+
stepCount: steps.length,
|
|
1513
|
+
executed: results.filter((entry) => entry.ok).length,
|
|
1514
|
+
failed,
|
|
1515
|
+
steps: results,
|
|
1516
|
+
actedAt: new Date().toISOString()
|
|
1517
|
+
};
|
|
1518
|
+
const lastCursor = [...results].reverse().find((entry) => entry.cursor !== undefined);
|
|
1519
|
+
if (lastCursor !== undefined) value.cursor = lastCursor.cursor;
|
|
1520
|
+
const lastForeground = [...results].reverse().find((entry) => entry.foregroundTitle !== undefined);
|
|
1521
|
+
if (lastForeground !== undefined) value.foregroundTitle = lastForeground.foregroundTitle;
|
|
1522
|
+
|
|
1523
|
+
if (wantsFrame) {
|
|
1524
|
+
try {
|
|
1525
|
+
await abortableWait(Number.isSafeInteger(args.settleMs) ? args.settleMs : config.settleMs, exec?.signal);
|
|
1526
|
+
const captureArgs = { includeCursor: false };
|
|
1527
|
+
if (typeof args.window === 'string' && args.window.trim().length > 0) captureArgs.window = args.window.trim();
|
|
1528
|
+
else if (args.target === 'all') captureArgs.screen = 'all';
|
|
1529
|
+
else if (args.target === 'screen') captureArgs.screen = 'primary';
|
|
1530
|
+
else if (typeof value.foregroundTitle === 'string' && value.foregroundTitle.length > 0) captureArgs.window = value.foregroundTitle;
|
|
1531
|
+
else captureArgs.screen = 'primary';
|
|
1532
|
+
if (typeof args.region === 'string' && args.region.trim().length > 0) captureArgs.region = args.region.trim();
|
|
1533
|
+
const frame = await capture(ctx, exec, config, { ...captureArgs, forceImage: args.forceImage === true });
|
|
1534
|
+
value.capture = frame;
|
|
1535
|
+
value.capturePath = frame.path;
|
|
1536
|
+
if (frame.frameHash !== undefined) value.frameHash = frame.frameHash;
|
|
1537
|
+
if (frame.unchanged !== undefined) value.unchanged = frame.unchanged;
|
|
1538
|
+
if (frame.imageSkipped !== undefined) value.imageSkipped = frame.imageSkipped;
|
|
1539
|
+
if (frame.image === undefined && frame.imageError !== undefined) value.captureError = frame.imageError;
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
value.captureError = error instanceof Error ? error.message : String(error);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
return pruneAbsent(value);
|
|
1545
|
+
},
|
|
1546
|
+
presentCall(args) {
|
|
1547
|
+
const count = Array.isArray(args?.steps) ? args.steps.length : 0;
|
|
1548
|
+
return {
|
|
1549
|
+
card: 'generic',
|
|
1550
|
+
title: `Desktop batch (${count} step${count === 1 ? '' : 's'})`,
|
|
1551
|
+
kind: 'execute'
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
}));
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
// The warm worker lives exactly as long as the plugin does.
|
|
1558
|
+
try {
|
|
1559
|
+
ctx.effect(() => () => {
|
|
1560
|
+
void actionWorker.stop();
|
|
1561
|
+
});
|
|
1562
|
+
} catch {
|
|
1563
|
+
// A host without an effect surface keeps the worker until the process exits.
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
export { Config, apply, inject, name, pruneAbsent, windowInfoOf };
|