@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Logic Tan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @logictan/dsh-desktop-agent
2
+
3
+ A DSH desktop agent that drives **one window on your own desktop** through the
4
+ Cua Driver tools, using **screenshots as its primary sense**.
5
+
6
+ Each step captures the target window, sends the picture to a vision-capable model
7
+ from DSH's own model directory, and performs the click, key, or text entry the
8
+ model chose. Nothing is hard-coded to a particular model and there is no second
9
+ API key: every call goes through `ctx.llm`.
10
+
11
+ ## Why screenshots instead of the accessibility tree
12
+
13
+ The accessibility tree is the right sense for ordinary applications, and this
14
+ plugin still uses it — but it cannot see a game. A game is a self-drawn surface:
15
+ it exposes no meaningful actionable nodes, and most native games additionally
16
+ filter input that is routed by process id. For those targets the only channel that
17
+ carries any information is the picture itself.
18
+
19
+ So the plugin routes by capability rather than by configuration:
20
+
21
+ | The resolved model declares `image` | Channel |
22
+ | --- | --- |
23
+ | yes | screenshot + a JSON action |
24
+ | no, or the field is absent | the driver's element table and markdown tree |
25
+
26
+ "Absent" is treated as "cannot see" on purpose. The host rewrites an image sent to
27
+ a route that does not accept one into placeholder text, after which the model is
28
+ describing a picture it never received — and answering confidently about it.
29
+
30
+ ## Requirements
31
+
32
+ The profile must mount the Cua Driver computer-use provider; the plugin dispatches
33
+ to the `cua_driver_native__*` tools it publishes and has no driver of its own.
34
+
35
+ ## Settings
36
+
37
+ `Settings → Plugins → desktop-agent → configuration`:
38
+
39
+ | Field | Meaning |
40
+ | --- | --- |
41
+ | Vision provider / model | The route used for each decision, chosen from the models that declare image input. All empty = the session's own current route. |
42
+ | Vision reasoning effort | Optional, and only offered for models that declare one. |
43
+ | Max steps | Actions per run; defaults to 40. |
44
+ | Screenshot long edge | Pixels; defaults to 1568. Lower saves tokens, but too low hides the controls. |
45
+ | Delivery | `background` (default) never steals focus; `foreground` is required by surfaces that filter per-process-routed input, such as canvas apps and games. |
46
+
47
+ ## Tool
48
+
49
+ `desktop_agent({ app, goal, windowId })` resolves one window, runs the
50
+ observe/decide/act loop until `DONE`/`BLOCKED` or a cap is reached, and returns a
51
+ structured trace. `app` accepts a bundle id, an application name, or a window
52
+ title; omitting it uses the frontmost window.
53
+
54
+ Only on-screen windows are eligible. A covered window cannot be captured or acted
55
+ on, so the tool fails with the window named rather than screenshotting whatever
56
+ happened to be in front.
57
+
58
+ ## Scope
59
+
60
+ v1 drives one window at a time, in the foreground of that window's own desktop,
61
+ and is meant for tasks whose decisions are measured in seconds — turn-based and
62
+ strategy games, simulations, settings panels, file managers.
63
+
64
+ It is not for real-time action games: one vision decision costs 300–800 ms, an
65
+ order of magnitude away from human reaction time. It does not read or write
66
+ process memory, and it makes no attempt to evade anti-cheat protection.
67
+
68
+ ## Coordinates
69
+
70
+ Action coordinates are pixels in the screenshot the model was shown, measured
71
+ from its top-left corner. The driver resolves them against the capture the caller
72
+ last saw, so every step re-observes before it acts — a new capture of a window
73
+ also invalidates the element tokens of the previous one.
@@ -0,0 +1,18 @@
1
+ # dsh-desktop-agent bundle patch layer.
2
+ #
3
+ # One bare plugin row. The package ships two halves — the host half
4
+ # (exports ".") registers the `desktop_agent` model tool and exports the
5
+ # `Config` schema the Plugins page derives its form from, the browser half
6
+ # (exports "./client") registers the Plugins-page configuration card.
7
+ #
8
+ # The row must stay a BARE package name: the web plugin table locates each
9
+ # package's `dsh.client` manifest from the specifier of the loader row that
10
+ # mounts it, and that lookup accepts only a bare name — a subpath row resolves
11
+ # to no manifest, so the browser half would silently never load.
12
+ #
13
+ # Row id `desktop-agent` equals the host half's `export const name` in
14
+ # src/index.js. It is checked by hand against every other row id in this
15
+ # repository; `aggregate.mjs --check` does not validate id uniqueness.
16
+ - insert:
17
+ - id: desktop-agent
18
+ name: '@logictan/dsh-desktop-agent'
package/lib/actions.js ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The action vocabulary and its projection onto the Cua Driver tools.
3
+ *
4
+ * One decision from the model becomes exactly one driver call. The vocabulary is
5
+ * deliberately the driver's own, minus everything this plugin cannot verify or
6
+ * does not need: no `drag`, no clipboard, no recording, no browser family. Each
7
+ * entry here maps to a tool the driver actually exposes, so a decision the model
8
+ * can express is a decision that can be executed.
9
+ *
10
+ * Coordinates travel as screenshot pixels in the frame the last observation
11
+ * carried — see `geometry.js` for why that frame, and only that frame, is
12
+ * authoritative.
13
+ *
14
+ * @module @logictan/dsh-desktop-agent/actions
15
+ */
16
+ import { isFrame, within } from './geometry.js';
17
+
18
+ /** Every action kind the decision protocol accepts. */
19
+ export const ACTION_KINDS = [
20
+ 'click',
21
+ 'double_click',
22
+ 'right_click',
23
+ 'type_text',
24
+ 'press_key',
25
+ 'hotkey',
26
+ 'scroll',
27
+ 'set_value',
28
+ 'launch_app',
29
+ 'wait',
30
+ 'done',
31
+ 'blocked',
32
+ ];
33
+
34
+ /** Mouse actions, each addressed by a screenshot pixel. */
35
+ const POINTER_KINDS = ['click', 'double_click', 'right_click'];
36
+
37
+ /** Actions that end the run rather than reaching the driver. */
38
+ const TERMINAL_KINDS = ['done', 'blocked'];
39
+
40
+ /** Driver tool name per action kind. */
41
+ const TOOL_BY_KIND = {
42
+ click: 'cua_driver_native__click',
43
+ double_click: 'cua_driver_native__double_click',
44
+ right_click: 'cua_driver_native__right_click',
45
+ type_text: 'cua_driver_native__type_text',
46
+ press_key: 'cua_driver_native__press_key',
47
+ hotkey: 'cua_driver_native__hotkey',
48
+ scroll: 'cua_driver_native__scroll',
49
+ set_value: 'cua_driver_native__set_value',
50
+ launch_app: 'cua_driver_native__launch_app',
51
+ };
52
+
53
+ /**
54
+ * Validate one decision and build the driver call it maps to.
55
+ *
56
+ * Validation is deliberately strict about the fields an action cannot run
57
+ * without. A model that names `click` and forgets the point has not made an
58
+ * ambiguous decision, it has made an incomplete one, and reporting that back is
59
+ * what lets the next decision correct it — silently defaulting to the window
60
+ * centre would perform an action nobody asked for.
61
+ *
62
+ * @param decision - the parsed decision.
63
+ * @param context - the observation frame and the target the action applies to.
64
+ * @returns the driver tool name and its arguments, or a terminal outcome.
65
+ * @throws {Error} when the decision is missing a field its action requires.
66
+ */
67
+ export function plan(decision, context) {
68
+ const { kind } = decision;
69
+ if (!ACTION_KINDS.includes(kind)) {
70
+ throw new Error(`desktop_agent: "${kind}" is not an action. Choose one of: ${ACTION_KINDS.join(', ')}.`);
71
+ }
72
+ if (TERMINAL_KINDS.includes(kind)) return { terminal: kind };
73
+ if (kind === 'wait') {
74
+ const ms = decision.ms ?? 1000;
75
+ if (!Number.isFinite(ms) || ms < 0) {
76
+ throw new Error('desktop_agent: wait needs a non-negative number of milliseconds.');
77
+ }
78
+ return { wait: ms };
79
+ }
80
+
81
+ if (kind === 'launch_app') {
82
+ const args = {};
83
+ if (typeof decision.bundleId === 'string' && decision.bundleId !== '') args.bundle_id = decision.bundleId;
84
+ if (typeof decision.name === 'string' && decision.name !== '') args.name = decision.name;
85
+ if (args.bundle_id === undefined && args.name === undefined) {
86
+ throw new Error('desktop_agent: launch_app needs either bundleId or name.');
87
+ }
88
+ return { tool: TOOL_BY_KIND.launch_app, args };
89
+ }
90
+
91
+ const args = { pid: context.target.pid, window_id: context.target.windowId };
92
+ if (context.deliveryMode === 'foreground') args.delivery_mode = 'foreground';
93
+
94
+ if (POINTER_KINDS.includes(kind)) {
95
+ const point = requirePoint(decision, context);
96
+ const modifiers = Array.isArray(decision.modifiers) ? decision.modifiers : [];
97
+
98
+ // A MODIFIED double click is expressed as a two-count click: click is the
99
+ // only pointer tool whose schema carries both `count` and `modifier`
100
+ // (double_click carries modifier but no count), so one call can say "twice,
101
+ // with this modifier" without splitting the gesture across two tools.
102
+ if (kind === 'double_click' && modifiers.length > 0) {
103
+ args.x = point.x;
104
+ args.y = point.y;
105
+ args.count = 2;
106
+ args.modifier = modifiers;
107
+ return { tool: TOOL_BY_KIND.click, args };
108
+ }
109
+
110
+ args.x = point.x;
111
+ args.y = point.y;
112
+ if (kind === 'click' && Number.isInteger(decision.count) && decision.count > 1) args.count = decision.count;
113
+ if (kind !== 'double_click' && modifiers.length > 0) args.modifier = modifiers;
114
+ return { tool: TOOL_BY_KIND[kind], args };
115
+ }
116
+
117
+ if (kind === 'scroll') {
118
+ if (typeof decision.direction !== 'string') throw new Error('desktop_agent: scroll needs a direction.');
119
+ args.direction = decision.direction;
120
+ if (Number.isInteger(decision.amount)) args.amount = decision.amount;
121
+ if (typeof decision.by === 'string' && decision.by !== '') args.by = decision.by;
122
+ // A wheel event lands on whatever is under the cursor, which is the only way
123
+ // to reach a nested scroller that never takes keyboard focus. When the
124
+ // decision names a point, route through the pixel-wheel path.
125
+ if (decision.x !== undefined && decision.y !== undefined) {
126
+ const point = requirePoint(decision, context);
127
+ args.x = point.x;
128
+ args.y = point.y;
129
+ }
130
+ return { tool: TOOL_BY_KIND.scroll, args };
131
+ }
132
+
133
+ if (kind === 'type_text') {
134
+ if (typeof decision.text !== 'string' || decision.text === '') {
135
+ throw new Error('desktop_agent: type_text needs the text to type.');
136
+ }
137
+ args.text = decision.text;
138
+ return { tool: TOOL_BY_KIND.type_text, args };
139
+ }
140
+
141
+ if (kind === 'press_key') {
142
+ if (typeof decision.key !== 'string' || decision.key === '') {
143
+ throw new Error('desktop_agent: press_key needs a key.');
144
+ }
145
+ args.key = decision.key;
146
+ if (Array.isArray(decision.modifiers) && decision.modifiers.length > 0) args.modifiers = decision.modifiers;
147
+ return { tool: TOOL_BY_KIND.press_key, args };
148
+ }
149
+
150
+ if (kind === 'hotkey') {
151
+ if (!Array.isArray(decision.keys) || decision.keys.length < 2) {
152
+ throw new Error('desktop_agent: hotkey needs at least one modifier and one key, e.g. ["cmd", "s"].');
153
+ }
154
+ args.keys = decision.keys;
155
+ return { tool: TOOL_BY_KIND.hotkey, args };
156
+ }
157
+
158
+ // set_value: addressed by the element token from the latest AX observation.
159
+ if (typeof decision.token !== 'string' || decision.token === '') {
160
+ throw new Error('desktop_agent: set_value needs the element token of the target.');
161
+ }
162
+ if (typeof decision.value !== 'string') throw new Error('desktop_agent: set_value needs a value.');
163
+ args.element_token = decision.token;
164
+ args.value = decision.value;
165
+ return { tool: TOOL_BY_KIND.set_value, args };
166
+ }
167
+
168
+ /**
169
+ * Read and bound-check a decision's point against the observation frame.
170
+ *
171
+ * @param decision - the decision carrying `x` and `y`.
172
+ * @param context - the observation frame the point is read in.
173
+ * @returns the integral point.
174
+ * @throws {Error} when the point is missing, non-numeric, or outside the frame.
175
+ */
176
+ function requirePoint(decision, context) {
177
+ const { x, y } = decision;
178
+ if (!isFrame(context.frame)) {
179
+ throw new Error(
180
+ 'desktop_agent: no screenshot frame is available for this action, so a coordinate cannot be placed. ' +
181
+ 'This run is on the accessibility channel; use set_value with an element token instead.',
182
+ );
183
+ }
184
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
185
+ throw new Error('desktop_agent: this action needs numeric x and y in the screenshot coordinate space.');
186
+ }
187
+ const point = { x: Math.round(x), y: Math.round(y) };
188
+ if (!within(point, context.frame)) {
189
+ throw new Error(
190
+ `desktop_agent: the point (${point.x}, ${point.y}) lies outside the ${context.frame.width}×${context.frame.height} screenshot; ` +
191
+ 're-read the coordinates from the screenshot you were shown.',
192
+ );
193
+ }
194
+ return point;
195
+ }
package/lib/ask.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The one model call this plugin makes: ask for the next action.
3
+ *
4
+ * The call goes through the host's own `ctx.llm`, so there is no second API key,
5
+ * no second provider configuration, and nothing for config sync to strip. It is
6
+ * also what makes the channel decision real: the SAME route that will answer is
7
+ * the one the plugin asks about image support before it decides whether to send a
8
+ * screenshot or an element table.
9
+ *
10
+ * @module @logictan/dsh-desktop-agent/ask
11
+ */
12
+ import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
13
+ import { INSTRUCTION, MAX_TOKENS, decisionPayload } from './request.js';
14
+ import { NO_ROUTE_MESSAGE } from './route.js';
15
+
16
+ /**
17
+ * The producer-owned source kind stamped on every message this plugin sends.
18
+ *
19
+ * Session format v4 refuses the retired `{ kind: 'plugin', plugin }` wrapper at
20
+ * native admission, so the identity rides `kind` itself under the host's
21
+ * documented `plugin:` prefix for a producer that is not a first-party plugin.
22
+ */
23
+ export const SOURCE = { kind: 'plugin:dsh-desktop-agent' };
24
+
25
+ /**
26
+ * Ask the routed model for one decision.
27
+ *
28
+ * @param input - the `ctx.llm` service, the route, the goal, the observation, the
29
+ * history, and the caller's cancellation signal.
30
+ * @returns the model's raw answer text.
31
+ * @throws {Error} an actionable message when the call failed or produced no text.
32
+ */
33
+ export async function ask(input) {
34
+ const { llm, route, goal, observation, history, signal } = input;
35
+
36
+ if (route.provider === '' || route.model === '') {
37
+ throw new Error(NO_ROUTE_MESSAGE);
38
+ }
39
+
40
+ const assembler = new BlockAssembler();
41
+ const options = {
42
+ provider: route.provider,
43
+ model: route.model,
44
+ messages: [
45
+ createUserMessage({ content: [{ type: 'text', text: INSTRUCTION }], source: SOURCE }),
46
+ createUserMessage({ content: contentOf(observation, decisionPayload({ goal, observation, history })), source: SOURCE }),
47
+ ],
48
+ maxTokens: MAX_TOKENS,
49
+ ...(route.reasoningEffort === '' ? {} : { reasoningEffort: route.reasoningEffort }),
50
+ ...(signal === undefined ? {} : { signal }),
51
+ };
52
+
53
+ for await (const chunk of llm.stream(options)) assembler.push(chunk);
54
+
55
+ const finish = assembler.finish;
56
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
57
+ throw new Error(`desktop_agent: the decision call failed (${finish.failure.message}).`);
58
+ }
59
+
60
+ const text = assembler
61
+ .blocks()
62
+ .filter((block) => block.type === 'text')
63
+ .map((block) => block.text)
64
+ .join('');
65
+
66
+ if (text.trim() === '') throw new Error('desktop_agent: the model returned no decision.');
67
+ return text;
68
+ }
69
+
70
+ /**
71
+ * Build the content blocks of the data message.
72
+ *
73
+ * The screenshot is carried as a durable attachment reference, which is the only
74
+ * shape the harness admits an image in. The bytes are committed through
75
+ * `ctx.attachments` first: an inline base64 block is not a valid request part,
76
+ * and a reference the store never issued would be refused at the provider
77
+ * boundary.
78
+ *
79
+ * @param observation - the observation the decision is made against.
80
+ * @param payload - the serializable data payload.
81
+ * @returns the content blocks, image first.
82
+ */
83
+ function contentOf(observation, payload) {
84
+ const text = { type: 'text', text: JSON.stringify(payload) };
85
+ if (observation.channel !== 'vision') return [text];
86
+ return [{ type: 'image', attachment: observation.attachment }, text];
87
+ }