@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/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
+ }
@@ -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
+ }