@logictan/dsh-desktop-agent 0.1.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/LICENSE +21 -0
- package/README.md +73 -0
- package/cordis.patch.yml +18 -0
- package/lib/actions.js +195 -0
- package/lib/ask.js +87 -0
- package/lib/client.js +835 -0
- package/lib/config.js +116 -0
- package/lib/geometry.js +56 -0
- package/lib/index.js +324 -0
- package/lib/loop.js +222 -0
- package/lib/observe.js +160 -0
- package/lib/request.js +127 -0
- package/lib/route.js +89 -0
- package/lib/routes.js +95 -0
- package/package.json +81 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration surface of the desktop agent.
|
|
3
|
+
*
|
|
4
|
+
* As of the 0.1.7-alpha.1 settings redesign this schema is the ONE contract
|
|
5
|
+
* every surface shares: the host half reads it when a run starts, and
|
|
6
|
+
* `dsh-settings` derives the Plugins-page form from it — keyed by the loader
|
|
7
|
+
* entry id, which is why {@link ENTRY_ID} must equal both the row id in
|
|
8
|
+
* `cordis.patch.yml` and the host half's `export const name`. The card ships as
|
|
9
|
+
* plain browser code and cannot import this module, so its field list is kept in
|
|
10
|
+
* step by hand — changing a key here means changing `src/client.js` too.
|
|
11
|
+
*
|
|
12
|
+
* Two rules follow from the redesign, and both are load-bearing:
|
|
13
|
+
*
|
|
14
|
+
* - The schema must be reachable as `entry.fiber.runtime.Config`, i.e. exported
|
|
15
|
+
* from the PACKAGE ENTRY (`lib/index.js`), not merely from this module. That
|
|
16
|
+
* is where `dsh-settings` looks; a schema exported only from a subpath is
|
|
17
|
+
* invisible and the entry simply has no form.
|
|
18
|
+
* - Every field must be `volatile()`. The marker is what makes the field
|
|
19
|
+
* editable without a remount — and it is also what makes the Loader hand
|
|
20
|
+
* `apply` a live `{ get() }` reference instead of a value, which is the only
|
|
21
|
+
* shape the host half reads.
|
|
22
|
+
*
|
|
23
|
+
* This plugin carries NO secret field of its own: every model call goes through
|
|
24
|
+
* the host's own `ctx.llm`, so there is no second key to enter and nothing for
|
|
25
|
+
* config sync to strip.
|
|
26
|
+
*
|
|
27
|
+
* @module @logictan/dsh-desktop-agent/config
|
|
28
|
+
*/
|
|
29
|
+
import z from '@deepseek-ai/schemastery';
|
|
30
|
+
import { DEFAULT_MAX_STEPS } from './loop.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The loader entry id this plugin's configuration form is keyed by.
|
|
34
|
+
*
|
|
35
|
+
* `dsh-settings` keys a form by the profile entry id, which the repo convention
|
|
36
|
+
* pins to the host half's `export const name`; the browser half passes the same
|
|
37
|
+
* string to `configForms.get`. A mismatch does not throw — the card simply binds
|
|
38
|
+
* a form that is never served, and every field renders as unset.
|
|
39
|
+
*/
|
|
40
|
+
export const ENTRY_ID = 'desktop-agent';
|
|
41
|
+
|
|
42
|
+
/** How an action reaches the target: the driver's own best-effort ladder. */
|
|
43
|
+
export const DELIVERY_MODES = ['background', 'foreground'];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Schema of the `desktop-agent` configuration.
|
|
47
|
+
*
|
|
48
|
+
* The vision route is three separate fields rather than one. Empty means "use
|
|
49
|
+
* the session's own current route", which is the only default that stays correct
|
|
50
|
+
* after config sync moves the file to a machine with different providers; a
|
|
51
|
+
* hard-coded model would point at a model the target may not have. It is also
|
|
52
|
+
* what makes the AX fallback reachable without any configuration at all: an
|
|
53
|
+
* unconfigured plugin asks the host whether the SESSION's route accepts images
|
|
54
|
+
* and picks its channel from that answer.
|
|
55
|
+
*
|
|
56
|
+
* `deliveryMode` defaults to `background` — the official ladder is
|
|
57
|
+
* background-first with `foreground` as the escalation, and the driver's
|
|
58
|
+
* `background_unavailable` refusal is the only signal that authorises the
|
|
59
|
+
* upgrade. Pre-selecting `foreground` is for canvas/game surfaces, which
|
|
60
|
+
* explicitly filter per-pid-routed events.
|
|
61
|
+
*
|
|
62
|
+
* `maxImageDimension` caps the long edge of every screenshot this plugin asks
|
|
63
|
+
* for. It is not cosmetic: the host refuses an oversized image at admission, and
|
|
64
|
+
* an image that is merely offloaded arrives at the model as placeholder text —
|
|
65
|
+
* the model would then be "looking" at nothing. The default matches the
|
|
66
|
+
* driver's own 1568 ceiling, so the two never disagree.
|
|
67
|
+
*/
|
|
68
|
+
export const Config = z.object({
|
|
69
|
+
visionProvider: z.string().default('').volatile(),
|
|
70
|
+
visionModel: z.string().default('').volatile(),
|
|
71
|
+
visionReasoningEffort: z.string().default('').volatile(),
|
|
72
|
+
maxSteps: z.number().step(1).min(1).default(DEFAULT_MAX_STEPS).volatile(),
|
|
73
|
+
maxImageDimension: z.number().step(1).min(200).default(1568).volatile(),
|
|
74
|
+
deliveryMode: z.union(DELIVERY_MODES).default('background').volatile(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The route one decision call uses.
|
|
79
|
+
*
|
|
80
|
+
* An explicitly configured pair wins. Otherwise the session's own route is used,
|
|
81
|
+
* mirroring how the host resolves an auxiliary model call — the same three-step
|
|
82
|
+
* fallback (configured pair, then the session's routed request, then its
|
|
83
|
+
* options) that the browser agent uses for its own helper call.
|
|
84
|
+
*
|
|
85
|
+
* The second argument is the tool EXECUTION CONTEXT, not the agent: that is what
|
|
86
|
+
* the tool body has in hand. The two are not interchangeable — reading agent
|
|
87
|
+
* fields off the context short-circuits every optional chain and silently
|
|
88
|
+
* reports "unconfigured" on a session that has a perfectly good route.
|
|
89
|
+
*
|
|
90
|
+
* @param config - the configuration in effect, as plain values (the host half
|
|
91
|
+
* unwraps the live references before calling).
|
|
92
|
+
* @param exec - the tool execution context; its `agent` is the caller.
|
|
93
|
+
* @returns the route to call; empty provider/model means "unconfigured".
|
|
94
|
+
*/
|
|
95
|
+
export function resolveVisionRoute(config, exec) {
|
|
96
|
+
if (config.visionProvider !== '' && config.visionModel !== '') {
|
|
97
|
+
return {
|
|
98
|
+
provider: config.visionProvider,
|
|
99
|
+
model: config.visionModel,
|
|
100
|
+
reasoningEffort: config.visionReasoningEffort,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const agent = exec?.agent;
|
|
105
|
+
const routed = agent?.session?.requestHeader?.()?.config;
|
|
106
|
+
if (routed?.provider && routed?.model) {
|
|
107
|
+
return { provider: routed.provider, model: routed.model, reasoningEffort: '' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const options = agent?.options;
|
|
111
|
+
if (options?.provider && options?.model) {
|
|
112
|
+
return { provider: options.provider, model: options.model, reasoningEffort: '' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { provider: '', model: '', reasoningEffort: '' };
|
|
116
|
+
}
|
package/lib/geometry.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coordinate contract between a screenshot and the driver's action tools.
|
|
3
|
+
*
|
|
4
|
+
* The driver's action tools accept the point in the PIXEL SPACE OF THE PNG THE
|
|
5
|
+
* CALLER LAST SAW, and its own field documentation says so outright: "The driver
|
|
6
|
+
* reverses Retina backing scale and any window-image downscale." Two facts pin
|
|
7
|
+
* this down, and both were measured against a live window rather than inferred:
|
|
8
|
+
*
|
|
9
|
+
* 1. `screenshot_scale` is NOT a multiplier. On a window reporting
|
|
10
|
+
* `screenshot_scale: 2`, clicking the screenshot pixel of a button hit that
|
|
11
|
+
* button; the same point doubled landed outside the window and the driver
|
|
12
|
+
* refused it as `lies outside window ...'s frame`.
|
|
13
|
+
* 2. `max_dimension` downscales the PNG and its reported dimensions together,
|
|
14
|
+
* and the action tools follow the downscale: a point computed from the
|
|
15
|
+
* returned `screenshot_width`/`screenshot_height` is the point that lands.
|
|
16
|
+
*
|
|
17
|
+
* There is therefore NO conversion to perform, and this module deliberately
|
|
18
|
+
* offers none. The model is shown the frame the driver will read, so the
|
|
19
|
+
* identity is the whole mapping; a scaling helper would be an invitation to
|
|
20
|
+
* reintroduce the `screenshot_scale` multiplication the driver rejects.
|
|
21
|
+
*
|
|
22
|
+
* @module @logictan/dsh-desktop-agent/geometry
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether one value is a usable coordinate frame.
|
|
27
|
+
*
|
|
28
|
+
* @param frame - the candidate.
|
|
29
|
+
* @returns whether it carries two positive dimensions.
|
|
30
|
+
*/
|
|
31
|
+
export function isFrame(frame) {
|
|
32
|
+
return (
|
|
33
|
+
frame !== null &&
|
|
34
|
+
typeof frame === 'object' &&
|
|
35
|
+
Number.isFinite(frame.width) &&
|
|
36
|
+
Number.isFinite(frame.height) &&
|
|
37
|
+
frame.width > 0 &&
|
|
38
|
+
frame.height > 0
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether a point lies inside a frame.
|
|
44
|
+
*
|
|
45
|
+
* The driver refuses an out-of-frame point outright rather than clamping it, so a
|
|
46
|
+
* decision that reads a coordinate off the screenshot but lands outside the
|
|
47
|
+
* window is caught here, where the failure names the decision, instead of at the
|
|
48
|
+
* driver, where it only names the number.
|
|
49
|
+
*
|
|
50
|
+
* @param point - the point in the frame's pixels.
|
|
51
|
+
* @param frame - the frame it should lie in.
|
|
52
|
+
* @returns whether the point is inside.
|
|
53
|
+
*/
|
|
54
|
+
export function within(point, frame) {
|
|
55
|
+
return isFrame(frame) && point.x >= 0 && point.y >= 0 && point.x < frame.width && point.y < frame.height;
|
|
56
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-desktop-agent — host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers one model-facing tool, `desktop_agent`, that runs a native-vision
|
|
5
|
+
* decision loop against the host desktop, plus the loopback route the settings
|
|
6
|
+
* card reads its model list from.
|
|
7
|
+
*
|
|
8
|
+
* The plugin owns no execution engine of its own. Every action is dispatched to
|
|
9
|
+
* the `cua_driver_native__*` tools the profile already mounts, through the tool
|
|
10
|
+
* registry's own `execute` pipeline — so a call this plugin makes is subject to
|
|
11
|
+
* the same guards, timeouts, and cancellation as one the model made directly, and
|
|
12
|
+
* the driver's own coordinate and delivery contracts are the only ones in play.
|
|
13
|
+
*
|
|
14
|
+
* Configuration arrives as `apply`'s second argument. As of the 0.1.7-alpha.1
|
|
15
|
+
* settings redesign the plugin's own `Config` schema is the only config surface —
|
|
16
|
+
* `dsh-settings` derives the Plugins-page form from `entry.fiber.runtime.Config`,
|
|
17
|
+
* keyed by the entry id — and every field of it is `volatile()`, so the Loader
|
|
18
|
+
* hands `apply` one live `{ get() }` reference per field rather than a value.
|
|
19
|
+
* The former `settings.register(ns, schema)` and `settings.get(ns)` seams were
|
|
20
|
+
* removed in that generation.
|
|
21
|
+
*
|
|
22
|
+
* `Config` is therefore re-exported from the package entry: that is the exact
|
|
23
|
+
* object `dsh-settings` looks for, and a schema exported only from `./config.js`
|
|
24
|
+
* would leave the entry with no form at all.
|
|
25
|
+
*
|
|
26
|
+
* `llm`, `attachments`, and `tools` are resolved through `ctx.get` rather than
|
|
27
|
+
* declared in `inject`. Cordis requires a declared `inject` before a service
|
|
28
|
+
* property may be read, and waiting on a service a profile does not provide is
|
|
29
|
+
* exactly what leaves an entry `pending` — which the Web UI's boot audit turns
|
|
30
|
+
* into a hard failure for the whole page, not just this plugin. Each service is
|
|
31
|
+
* checked at the point of use instead, and its absence is reported as an
|
|
32
|
+
* actionable error from the tool body.
|
|
33
|
+
*
|
|
34
|
+
* `webServer` is the one exception, and it is mounted through a nested
|
|
35
|
+
* `ctx.inject` rather than a `ctx.get` guard: it is an optional surface that can
|
|
36
|
+
* arrive after this plugin activates, and a one-shot `ctx.get` would read it as
|
|
37
|
+
* absent and never retry. See the route block in {@link apply}.
|
|
38
|
+
*
|
|
39
|
+
* @module @logictan/dsh-desktop-agent
|
|
40
|
+
*/
|
|
41
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
42
|
+
import { ToolCallId } from '@deepseek-ai/dsh-llm';
|
|
43
|
+
|
|
44
|
+
import { ENTRY_ID, resolveVisionRoute } from './config.js';
|
|
45
|
+
import { run } from './loop.js';
|
|
46
|
+
import { structured } from './observe.js';
|
|
47
|
+
import { makeRoutes } from './routes.js';
|
|
48
|
+
|
|
49
|
+
export { Config } from './config.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Plugin row id; must equal the row id in `cordis.patch.yml`.
|
|
53
|
+
*
|
|
54
|
+
* Taken from {@link ENTRY_ID} rather than written out again: the row id, the
|
|
55
|
+
* host half's `name`, and the id the browser half hands to `configForms.get`
|
|
56
|
+
* are one string, and a second literal is a second thing to forget.
|
|
57
|
+
*/
|
|
58
|
+
export const name = ENTRY_ID;
|
|
59
|
+
|
|
60
|
+
/** Services resolved at the point of use; see the module note above. */
|
|
61
|
+
export const inject = [];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Register the `desktop_agent` tool and the vision-catalog route.
|
|
65
|
+
*
|
|
66
|
+
* @param ctx - the plugin context.
|
|
67
|
+
* @param config - one live reference per {@link Config} field.
|
|
68
|
+
*/
|
|
69
|
+
export function apply(ctx, config) {
|
|
70
|
+
const tools = ctx.get('tools');
|
|
71
|
+
if (tools === null || tools === undefined || typeof tools !== 'object') {
|
|
72
|
+
ctx.logger?.warn?.('desktop_agent: the tools service is unavailable; the tool was not registered.');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const llm = ctx.get('llm');
|
|
77
|
+
if (llm === null || llm === undefined) {
|
|
78
|
+
ctx.logger?.warn?.('desktop_agent: the llm service is unavailable; the tool was not registered.');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The configuration in effect right now, as plain values.
|
|
84
|
+
*
|
|
85
|
+
* Resolved at each use rather than captured at activation: the Loader commits a
|
|
86
|
+
* Plugins-page edit into these very references instead of remounting the
|
|
87
|
+
* plugin, so a snapshot taken here would pin the values the profile started
|
|
88
|
+
* with and the card's save button would appear to do nothing.
|
|
89
|
+
*
|
|
90
|
+
* @returns the resolved configuration.
|
|
91
|
+
*/
|
|
92
|
+
function currentConfig() {
|
|
93
|
+
return {
|
|
94
|
+
visionProvider: config.visionProvider.get(),
|
|
95
|
+
visionModel: config.visionModel.get(),
|
|
96
|
+
visionReasoningEffort: config.visionReasoningEffort.get(),
|
|
97
|
+
maxSteps: config.maxSteps.get(),
|
|
98
|
+
maxImageDimension: config.maxImageDimension.get(),
|
|
99
|
+
deliveryMode: config.deliveryMode.get(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Dispatch one driver tool through the registry's own pipeline.
|
|
105
|
+
*
|
|
106
|
+
* Going through `tools.execute` rather than calling the driver directly is what
|
|
107
|
+
* makes these calls first-class: they traverse pre-execute policy and guards,
|
|
108
|
+
* honour the caller's cancellation, and — because the execution carries this
|
|
109
|
+
* call's own `token` as `parent` — are treated as nested sub-dispatches. That
|
|
110
|
+
* last part matters under a `ptc` deployment, where a model-direct call may
|
|
111
|
+
* only name `run_code`; without the parent token every action here would be
|
|
112
|
+
* denied as `UNKNOWN_TOOL`.
|
|
113
|
+
*
|
|
114
|
+
* The sub-call id follows the registry's own `<parent>:ptc:<n>` convention. The
|
|
115
|
+
* counter is passed in rather than held here: it belongs to one run, and a
|
|
116
|
+
* counter shared across runs would number two concurrent `desktop_agent` calls
|
|
117
|
+
* into each other's identities.
|
|
118
|
+
*
|
|
119
|
+
* @param toolName - the driver tool to call.
|
|
120
|
+
* @param args - its arguments.
|
|
121
|
+
* @param signal - the caller's cancellation.
|
|
122
|
+
* @param exec - the `desktop_agent` execution this call belongs to.
|
|
123
|
+
* @param subCalls - this run's running sub-dispatch count.
|
|
124
|
+
* @returns the canonical tool value.
|
|
125
|
+
* @throws {Error} with the driver's own message when the call failed.
|
|
126
|
+
*/
|
|
127
|
+
async function dispatch(toolName, args, signal, exec, subCalls) {
|
|
128
|
+
const callId = String(exec.callId) + ':ptc:' + subCalls;
|
|
129
|
+
const outcome = await tools.execute({
|
|
130
|
+
callId: ToolCallId(callId),
|
|
131
|
+
rootCallId: exec.rootCallId,
|
|
132
|
+
name: toolName,
|
|
133
|
+
arguments: args,
|
|
134
|
+
...(exec.agent === undefined ? {} : { agent: exec.agent }),
|
|
135
|
+
parent: exec.token,
|
|
136
|
+
signal,
|
|
137
|
+
});
|
|
138
|
+
if (outcome.isError) throw new Error(outcome.error.message);
|
|
139
|
+
return outcome.value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const definition = defineTool({
|
|
143
|
+
name: 'desktop_agent',
|
|
144
|
+
description:
|
|
145
|
+
'Drive one desktop window to accomplish a goal, using screenshots as the primary sense. ' +
|
|
146
|
+
'Resolves the target application, then runs an observation/decision loop: each step captures the window, ' +
|
|
147
|
+
'asks the configured model what to do, and performs the click, key, or text entry it chose. ' +
|
|
148
|
+
'Works on ordinary applications and on games that expose no usable accessibility tree, because it reads the ' +
|
|
149
|
+
'picture rather than the element table. Requires the computer-use Cua Driver tools to be mounted. ' +
|
|
150
|
+
'Note that a covered window cannot be driven, and a game that filters injected input needs foreground ' +
|
|
151
|
+
'delivery — the plugin settings control that. This tool waits for the run to finish and returns a structured ' +
|
|
152
|
+
'trace of every step.',
|
|
153
|
+
parameters: {
|
|
154
|
+
app: { type: 'string', description: 'Application to drive: a bundle id, an app name, or a window title. Omit to use the frontmost window.' },
|
|
155
|
+
goal: { type: 'string', description: 'What must be true when the task is finished.', required: true },
|
|
156
|
+
windowId: { type: 'integer', description: 'Exact window id from a prior listing. Omit to resolve the window from the app argument.' },
|
|
157
|
+
},
|
|
158
|
+
output: {
|
|
159
|
+
schema: { type: 'json', description: 'Structured trace: status, channel, window, steps, and the decision count.' },
|
|
160
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
161
|
+
},
|
|
162
|
+
async execute(args, exec) {
|
|
163
|
+
const current = currentConfig();
|
|
164
|
+
const attachments = ctx.get('attachments');
|
|
165
|
+
if (attachments === null || attachments === undefined) {
|
|
166
|
+
throw new Error('desktop_agent: no attachment store is mounted, so screenshots cannot be sent to the model.');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// One counter per run, so every sub-dispatch in this run has a distinct id
|
|
170
|
+
// even though the same driver tool is called repeatedly.
|
|
171
|
+
let subCalls = 0;
|
|
172
|
+
const call = (toolName, toolArgs, signal) => {
|
|
173
|
+
subCalls += 1;
|
|
174
|
+
return dispatch(toolName, toolArgs, signal, exec, subCalls);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const target = await resolveTarget({ dispatch: call, args, signal: exec.signal });
|
|
178
|
+
return await run({
|
|
179
|
+
dispatch: call,
|
|
180
|
+
target,
|
|
181
|
+
goal: args.goal,
|
|
182
|
+
llm,
|
|
183
|
+
attachments,
|
|
184
|
+
config: { ...current, route: resolveVisionRoute(current, exec) },
|
|
185
|
+
signal: exec.signal,
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
ctx.effect(() => tools.register(definition), 'desktop-agent: desktop_agent tool');
|
|
191
|
+
|
|
192
|
+
// The route mounts through a nested `inject`, NOT a `ctx.get` guard.
|
|
193
|
+
//
|
|
194
|
+
// `webServer` is provided by its own loader entry and is not necessarily up
|
|
195
|
+
// when this plugin activates. A `ctx.get('webServer')` read at that moment
|
|
196
|
+
// returns `undefined` and is never retried, so the settings card would
|
|
197
|
+
// silently lose its model list for the whole session -- measured, not
|
|
198
|
+
// theoretical: with the guard in place the route answered 401 from the
|
|
199
|
+
// generic `/api` prefix handler while a sibling plugin's own route answered
|
|
200
|
+
// 405 from its registered handler.
|
|
201
|
+
//
|
|
202
|
+
// A nested inject defers the callback until the service exists and re-runs it
|
|
203
|
+
// if the service is replaced. Its fiber is a child of this plugin rather than
|
|
204
|
+
// a loader entry, so a profile with no web server (a TUI, say) leaves nothing
|
|
205
|
+
// pending in the boot audit -- which is why this is also the right shape for
|
|
206
|
+
// an optional surface.
|
|
207
|
+
ctx.inject(['webServer'], (scoped) => {
|
|
208
|
+
scoped.effect(
|
|
209
|
+
() => {
|
|
210
|
+
const disposers = makeRoutes({ llm }).map((route) => scoped.webServer.register(route));
|
|
211
|
+
return () => {
|
|
212
|
+
for (const dispose of disposers) dispose();
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
'desktop-agent: vision-model catalog route',
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve the one window a run will drive.
|
|
222
|
+
*
|
|
223
|
+
* Resolution is by explicit window id first, then by matching the app argument
|
|
224
|
+
* against the live window list. Only on-screen windows are eligible: the driver's
|
|
225
|
+
* own guidance is that a covered window cannot be captured or acted on, and
|
|
226
|
+
* failing here names the window instead of producing a screenshot of whatever
|
|
227
|
+
* happened to be in front.
|
|
228
|
+
*
|
|
229
|
+
* @param input - the dispatch seam, the tool arguments, and the signal.
|
|
230
|
+
* @returns the resolved target.
|
|
231
|
+
* @throws {Error} when no window matched.
|
|
232
|
+
*/
|
|
233
|
+
async function resolveTarget(input) {
|
|
234
|
+
const { dispatch, args, signal } = input;
|
|
235
|
+
|
|
236
|
+
const value = await dispatch('cua_driver_native__list_windows', {}, signal);
|
|
237
|
+
const payload = structured(value, 'list_windows');
|
|
238
|
+
const windows = Array.isArray(payload.windows) ? payload.windows : [];
|
|
239
|
+
if (windows.length === 0) throw new Error('desktop_agent: the driver listed no windows.');
|
|
240
|
+
|
|
241
|
+
if (args.windowId !== undefined) {
|
|
242
|
+
const exact = windows.find((window) => window.window_id === args.windowId);
|
|
243
|
+
if (exact === undefined) throw new Error('desktop_agent: no window has id ' + args.windowId + '.');
|
|
244
|
+
if (exact.is_on_screen !== true) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
'desktop_agent: window ' + args.windowId + ' (' + (exact.app_name ?? 'unknown') +
|
|
247
|
+
') is not on screen, so it cannot be captured or acted on.',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return targetOf(exact);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const onScreen = windows.filter((window) => window.is_on_screen === true);
|
|
254
|
+
if (args.app === undefined || args.app === '') {
|
|
255
|
+
// No app argument means "the frontmost window" literally, so this path ranks
|
|
256
|
+
// by z_index alone. The title rule below is deliberately NOT applied here:
|
|
257
|
+
// its candidates span every app, so passing over a title-less frontmost
|
|
258
|
+
// window could hand back a different application's window.
|
|
259
|
+
const frontmost = topmost(onScreen);
|
|
260
|
+
if (frontmost === undefined) throw new Error('desktop_agent: no window is currently on screen to drive.');
|
|
261
|
+
return targetOf(frontmost);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const needle = args.app.toLowerCase();
|
|
265
|
+
const matches = onScreen.filter(
|
|
266
|
+
(window) =>
|
|
267
|
+
String(window.app_name ?? '').toLowerCase().includes(needle) ||
|
|
268
|
+
String(window.title ?? '').toLowerCase().includes(needle),
|
|
269
|
+
);
|
|
270
|
+
if (matches.length === 0) {
|
|
271
|
+
const names = [...new Set(onScreen.map((window) => window.app_name).filter(Boolean))];
|
|
272
|
+
throw new Error(
|
|
273
|
+
'desktop_agent: no on-screen window matches "' + args.app + '". Open windows: ' + (names.join(', ') || '(none)') + '.',
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return targetOf(bestMatch(matches));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Choose the window to drive from the candidates an app argument matched.
|
|
281
|
+
*
|
|
282
|
+
* Ranking is by z_index, EXCEPT that a title-less window loses to a titled one.
|
|
283
|
+
* A popover or tooltip is a separate on-screen window owned by the same app and
|
|
284
|
+
* stacked above the window it belongs to, so z_index alone picks the overlay —
|
|
285
|
+
* measured on the Calculator, where the 模式 popover (328x101, title "") beat the
|
|
286
|
+
* real window (230x408, title "计算器") and the run then captured a menu it could
|
|
287
|
+
* not act on. The driver's own AX root for a real window is `AXWindow "<title>"`;
|
|
288
|
+
* the overlay has no title at all.
|
|
289
|
+
*
|
|
290
|
+
* The needle matches app_name OR title, so these candidates are not guaranteed
|
|
291
|
+
* to share one app. Preferring a titled window is still right — a title-less
|
|
292
|
+
* window is an overlay whoever owns it — and a title-less window is only ever
|
|
293
|
+
* passed over for a titled sibling. When all candidates are title-less — a
|
|
294
|
+
* fullscreen game, say — the z_index order is unchanged, so this narrows the
|
|
295
|
+
* choice rather than replacing it.
|
|
296
|
+
*
|
|
297
|
+
* @param candidates - the on-screen windows an app argument matched; never empty,
|
|
298
|
+
* so the choice is always defined.
|
|
299
|
+
* @returns the chosen window.
|
|
300
|
+
*/
|
|
301
|
+
function bestMatch(candidates) {
|
|
302
|
+
const titled = candidates.filter((window) => String(window.title ?? '') !== '');
|
|
303
|
+
return topmost(titled.length > 0 ? titled : candidates);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The frontmost of a set of windows.
|
|
308
|
+
*
|
|
309
|
+
* @param windows - the windows to rank.
|
|
310
|
+
* @returns the highest z_index window, or undefined when there is none.
|
|
311
|
+
*/
|
|
312
|
+
function topmost(windows) {
|
|
313
|
+
return [...windows].sort((a, b) => (b.z_index ?? 0) - (a.z_index ?? 0))[0];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Narrow one listed window to the target shape the loop uses. */
|
|
317
|
+
function targetOf(window) {
|
|
318
|
+
return {
|
|
319
|
+
pid: window.pid,
|
|
320
|
+
windowId: window.window_id,
|
|
321
|
+
app: window.app_name ?? '',
|
|
322
|
+
title: window.title ?? '',
|
|
323
|
+
};
|
|
324
|
+
}
|