deepseek-computer-use 0.1.0 → 0.1.2
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/CHANGELOG.md +16 -0
- package/README.md +89 -29
- package/assets/cover-whale-girl.png +0 -0
- package/lib/bridge.js +153 -150
- package/lib/build-helper.mjs +18 -4
- package/lib/build.mjs +2 -2
- package/lib/index.js +53 -34
- package/lib/parameters.js +50 -0
- package/lib/safety.js +2 -9
- package/lib/tools.js +131 -32
- package/native/Driver.cs +145 -42
- package/native/Overlay.cs +92 -29
- package/native/Program.cs +6 -1
- package/package.json +8 -4
- package/skills/computer-use/SKILL.md +5 -2
package/lib/build-helper.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module deepseek-computer-use/build-helper
|
|
10
10
|
*/
|
|
11
11
|
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
12
13
|
import fs from 'node:fs';
|
|
13
14
|
import path from 'node:path';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
@@ -76,11 +77,24 @@ export function ensureHelper({ force = false, exe = HELPER_EXE, log } = {}) {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
fs.mkdirSync(path.dirname(exe), { recursive: true });
|
|
79
|
-
const args = ['/nologo', '/target:exe', '/platform:anycpu', '/out:' + exe, ...SOURCES, ...refs];
|
|
80
80
|
log?.('[dsh-computer-use] compiling helper with ' + compiler);
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
const compile = output => execFileSync(compiler, ['/nologo', '/target:exe', '/platform:anycpu', '/out:' + output, ...SOURCES, ...refs], { encoding: 'utf8', windowsHide: true });
|
|
82
|
+
try {
|
|
83
|
+
compile(exe);
|
|
84
|
+
if (!fs.existsSync(exe)) throw new Error('helper compilation produced no executable');
|
|
85
|
+
return { exe, built: true };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// Windows locks a running exe. Compile alongside it during an update rather
|
|
88
|
+
// than stopping a helper owned by another DSH session.
|
|
89
|
+
if (!fs.existsSync(exe) || !String(error.stdout ?? '').includes('CS0016')) throw error;
|
|
90
|
+
const hash = createHash('sha256');
|
|
91
|
+
for (const source of SOURCES) hash.update(fs.readFileSync(source));
|
|
92
|
+
const cached = path.join(path.dirname(exe), path.basename(exe, '.exe') + '-' + hash.digest('hex').slice(0, 16) + '.exe');
|
|
93
|
+
if (isHelperFresh(cached)) return { exe: cached, built: false };
|
|
94
|
+
log?.('[dsh-computer-use] helper is in use; compiling alongside it');
|
|
95
|
+
compile(cached);
|
|
96
|
+
return { exe: cached, built: true };
|
|
97
|
+
}
|
|
84
98
|
}
|
|
85
99
|
|
|
86
100
|
/** The in-use indicator (breathing frame + virtual cursor) is a separate win-exe. */
|
package/lib/build.mjs
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* capture Windows.Graphics.Capture (needs .NET SDK)
|
|
7
7
|
* audio WASAPI loopback recorder (needs .NET SDK)
|
|
8
8
|
*/
|
|
9
|
-
import { ensureHelper, ensureOverlay,
|
|
9
|
+
import { ensureHelper, ensureOverlay, OVERLAY_EXE } from './build-helper.mjs';
|
|
10
10
|
import { buildNative } from './build-native.mjs';
|
|
11
11
|
|
|
12
12
|
const log = message => console.log(message);
|
|
13
13
|
|
|
14
14
|
const helper = ensureHelper({ force: process.argv.includes('--force'), log });
|
|
15
|
-
log('[dsh-computer-use] helper ' + (helper.built ? 'built' : 'up to date') + ' -> ' +
|
|
15
|
+
log('[dsh-computer-use] helper ' + (helper.built ? 'built' : 'up to date') + ' -> ' + helper.exe);
|
|
16
16
|
|
|
17
17
|
const overlay = ensureOverlay({ force: process.argv.includes('--force'), log });
|
|
18
18
|
log('[dsh-computer-use] overlay ' + (overlay.built ? 'built' : 'up to date') + ' -> ' + OVERLAY_EXE);
|
package/lib/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { HelperBridge } from './bridge.js';
|
|
|
16
16
|
import { resolveConfig } from './config.js';
|
|
17
17
|
import { ensureHelper, ensureOverlay, findCompiler, HELPER_EXE, isHelperFresh, isOverlayFresh } from './build-helper.mjs';
|
|
18
18
|
import { buildTools } from './tools.js';
|
|
19
|
+
import { assertTurnOpen, turnTokenOf } from './safety.js';
|
|
19
20
|
|
|
20
21
|
export const name = 'deepseek-computer-use';
|
|
21
22
|
/** Only the tool registry is mandatory; skills/attachments/approval are optional. */
|
|
@@ -52,67 +53,70 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
const states = new Map();
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
const runtimes = new Map();
|
|
57
|
+
const defaultSession = Symbol('unscoped');
|
|
58
|
+
let disposed = false;
|
|
57
59
|
const exePath = config.helperPath ?? HELPER_EXE;
|
|
60
|
+
let activeExePath = exePath;
|
|
58
61
|
|
|
59
62
|
async function startBridge() {
|
|
60
63
|
const built = ensureHelper({ exe: exePath, log: warn });
|
|
64
|
+
activeExePath = built.exe;
|
|
61
65
|
// The in-use indicator is a second exe; build it eagerly so the very first
|
|
62
66
|
// action already shows the breathing frame instead of a silent no-op.
|
|
63
67
|
try { ensureOverlay({ log: warn }); } catch (error) { warn('overlay build skipped: ' + error.message); }
|
|
64
68
|
return new HelperBridge({ exe: built.exe, timeoutMs: config.timeoutMs, log: warn });
|
|
65
69
|
}
|
|
66
70
|
|
|
67
|
-
async function getBridge() {
|
|
68
|
-
if (bridge !== null) return bridge;
|
|
69
|
-
if (starting === null) starting = startBridge();
|
|
70
|
-
try {
|
|
71
|
-
bridge = await starting;
|
|
72
|
-
return bridge;
|
|
73
|
-
} catch (error) {
|
|
74
|
-
starting = null;
|
|
75
|
-
throw error;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
let configured = false;
|
|
80
|
-
let lastToken = null;
|
|
81
|
-
|
|
82
71
|
async function request(method, params = {}, opts = {}) {
|
|
83
|
-
|
|
72
|
+
if (disposed) throw new Error('the Computer Use plugin was disposed');
|
|
73
|
+
const key = opts.session ?? defaultSession;
|
|
74
|
+
let runtime = runtimes.get(key);
|
|
75
|
+
if (!runtime) {
|
|
76
|
+
runtime = { bridge: await startBridge(), configured: false, lastToken: null };
|
|
77
|
+
runtimes.set(key, runtime);
|
|
78
|
+
}
|
|
79
|
+
const active = runtime.bridge;
|
|
80
|
+
if (!active.running) runtime.configured = false;
|
|
84
81
|
|
|
85
|
-
if (!configured) {
|
|
82
|
+
if (!runtime.configured) {
|
|
86
83
|
// The helper refuses windows whose app was never approved; keep that switch in
|
|
87
84
|
// sync with the plugin policy.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
} catch { /* older helper */ }
|
|
91
|
-
configured = true;
|
|
85
|
+
await active.request('configure', { requireAppApproval: config.appConsent !== 'never' }, { timeoutMs: config.timeoutMs, signal: opts.signal });
|
|
86
|
+
runtime.configured = true;
|
|
92
87
|
}
|
|
93
88
|
|
|
94
89
|
const token = opts.turnToken;
|
|
95
|
-
if (token !== undefined && token !== lastToken) {
|
|
96
|
-
if (lastToken !== null) {
|
|
90
|
+
if (token !== undefined && token !== runtime.lastToken) {
|
|
91
|
+
if (runtime.lastToken !== null) {
|
|
97
92
|
// Turn boundary: drop element snapshots, screenshot regions and the Esc block.
|
|
98
|
-
|
|
93
|
+
await active.request('end_turn', {}, { timeoutMs: 5000, signal: opts.signal });
|
|
99
94
|
}
|
|
100
|
-
lastToken = token;
|
|
95
|
+
runtime.lastToken = token;
|
|
101
96
|
}
|
|
102
97
|
|
|
103
98
|
const timeoutMs = opts.timeoutMs ?? (method === 'launch_app' ? config.launchTimeoutMs : config.timeoutMs);
|
|
104
|
-
|
|
99
|
+
if (opts.session) assertTurnOpen({ agent: { session: opts.session } });
|
|
100
|
+
try {
|
|
101
|
+
return await active.request(method, params, { ...opts, timeoutMs });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (active.running && !['configure', 'approve_app', 'invalidate_state'].includes(method)) {
|
|
104
|
+
try { await active.request('invalidate_state', {}, { timeoutMs: 2000 }); } catch { /* transport failure stops the child */ }
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
const describeHelper = () => {
|
|
108
111
|
try {
|
|
109
112
|
return {
|
|
110
|
-
path:
|
|
111
|
-
exists: fs.existsSync(
|
|
112
|
-
fresh: isHelperFresh(
|
|
113
|
+
path: activeExePath,
|
|
114
|
+
exists: fs.existsSync(activeExePath),
|
|
115
|
+
fresh: isHelperFresh(activeExePath),
|
|
113
116
|
compiler: findCompiler(),
|
|
114
117
|
overlay: isOverlayFresh(),
|
|
115
118
|
platform: process.platform,
|
|
119
|
+
pids: [...runtimes.values()].map(runtime => runtime.bridge.pid).filter(pid => pid !== null),
|
|
116
120
|
};
|
|
117
121
|
} catch (error) {
|
|
118
122
|
return { path: exePath, error: error.message };
|
|
@@ -142,9 +146,24 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
if (typeof ctx.on === 'function') {
|
|
145
|
-
ctx.on('
|
|
146
|
-
|
|
147
|
-
|
|
149
|
+
ctx.on('session/event', (session, event) => {
|
|
150
|
+
if (event?.type !== 'turn/end') return;
|
|
151
|
+
const runtime = runtimes.get(session);
|
|
152
|
+
if (runtime) return runtime.bridge.stopTurn(turnTokenOf({ agent: { session } })).catch(error => warn(error.message));
|
|
153
|
+
});
|
|
154
|
+
ctx.on('session/disposed', session => {
|
|
155
|
+
const runtime = runtimes.get(session);
|
|
156
|
+
if (runtime) {
|
|
157
|
+
runtimes.delete(session);
|
|
158
|
+
return runtime.bridge.dispose().catch(error => warn(error.message));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
ctx.on('dispose', async () => {
|
|
162
|
+
disposed = true;
|
|
163
|
+
// The overlay hides the system cursor while it is up; make sure teardown puts it
|
|
164
|
+
// back even if the overlay process was killed instead of exiting cleanly.
|
|
165
|
+
await Promise.allSettled([...runtimes.values()].map(({ bridge }) => bridge.dispose()));
|
|
166
|
+
runtimes.clear();
|
|
148
167
|
for (const dispose of disposers) {
|
|
149
168
|
try { dispose(); } catch { /* registry may be tearing down */ }
|
|
150
169
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Compile the local parameter shorthand to the JSON Schema DSH exposes to models. */
|
|
2
|
+
export function compileParameters(properties) {
|
|
3
|
+
const compiled = {};
|
|
4
|
+
const required = [];
|
|
5
|
+
for (const [name, spec] of Object.entries(properties)) {
|
|
6
|
+
if (spec.required === true) required.push(name);
|
|
7
|
+
const { required: _required, ...node } = spec;
|
|
8
|
+
if (node.type === 'object') Object.assign(node, compileParameters(node.properties ?? {}));
|
|
9
|
+
if (node.items) node.items = compileParameters({ item: node.items }).properties.item;
|
|
10
|
+
if (node.oneOf) node.oneOf = node.oneOf.map(branch => compileParameters({ branch }).properties.branch);
|
|
11
|
+
compiled[name] = node;
|
|
12
|
+
}
|
|
13
|
+
return { type: 'object', properties: compiled, ...(required.length ? { required } : {}) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** DSH raw registrations own argument validation; reject before starting the helper. */
|
|
17
|
+
export function assertArguments(schema, value, at = 'arguments') {
|
|
18
|
+
const fail = detail => {
|
|
19
|
+
throw Object.assign(new Error(at + ': ' + detail), { code: 'invalid_arguments' });
|
|
20
|
+
};
|
|
21
|
+
if (schema.oneOf) {
|
|
22
|
+
let matches = 0;
|
|
23
|
+
for (const branch of schema.oneOf) {
|
|
24
|
+
try { assertArguments(branch, value, at); matches += 1; } catch (error) {
|
|
25
|
+
if (error.code !== 'invalid_arguments') throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (matches !== 1) fail('must match exactly one supported shape');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (schema.type === 'object') {
|
|
32
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) fail('must be an object');
|
|
33
|
+
for (const key of schema.required ?? []) {
|
|
34
|
+
if (!Object.hasOwn(value, key)) fail('missing required field ' + key);
|
|
35
|
+
}
|
|
36
|
+
for (const [key, item] of Object.entries(value)) {
|
|
37
|
+
const property = schema.properties?.[key];
|
|
38
|
+
if (property) assertArguments(property, item, at + '.' + key);
|
|
39
|
+
else if (schema.additionalProperties === false) fail('unknown field ' + key);
|
|
40
|
+
}
|
|
41
|
+
} else if (schema.type === 'array') {
|
|
42
|
+
if (!Array.isArray(value)) fail('must be an array');
|
|
43
|
+
if (schema.items) value.forEach((item, i) => assertArguments(schema.items, item, at + '[' + i + ']'));
|
|
44
|
+
} else if (schema.type === 'number') {
|
|
45
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) fail('must be a finite number');
|
|
46
|
+
} else if (schema.type && typeof value !== schema.type) {
|
|
47
|
+
fail('must be a ' + schema.type);
|
|
48
|
+
}
|
|
49
|
+
if (schema.enum && !schema.enum.includes(value)) fail('unsupported value');
|
|
50
|
+
}
|
package/lib/safety.js
CHANGED
|
@@ -163,15 +163,8 @@ export function needsApproval(config, { toolName, label = '' }) {
|
|
|
163
163
|
*/
|
|
164
164
|
export async function requestApproval(ctx, exec, { toolName, reason }) {
|
|
165
165
|
const approval = ctx?.get?.('approval');
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
// the hard denies above still apply, and asking would only return "rejected".
|
|
169
|
-
try {
|
|
170
|
-
if (typeof approval?.effectivePolicy === 'function') {
|
|
171
|
-
const policy = approval.effectivePolicy(exec?.agent?.session);
|
|
172
|
-
if (policy === 'never') return;
|
|
173
|
-
}
|
|
174
|
-
} catch { /* policy lookup is best effort */ }
|
|
166
|
+
// DSH's approval policy 'never' rejects asks; it is not an implicit grant.
|
|
167
|
+
// Let the public seam enforce policy and persist the asked/decided audit pair.
|
|
175
168
|
if (approval === undefined || approval === null || typeof approval.request !== 'function') {
|
|
176
169
|
throw new PolicyError(
|
|
177
170
|
'approval_unavailable',
|
package/lib/tools.js
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
needsApproval,
|
|
22
22
|
requestApproval,
|
|
23
23
|
} from './safety.js';
|
|
24
|
+
import { assertArguments, compileParameters } from './parameters.js';
|
|
25
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
24
26
|
|
|
25
27
|
const WINDOW_SCHEMA = {
|
|
26
28
|
type: 'object',
|
|
@@ -28,7 +30,13 @@ const WINDOW_SCHEMA = {
|
|
|
28
30
|
description: 'Target window object taken from list_windows() / list_apps() / get_window_state(). Pass the same object back; only its numeric id is used for addressing.',
|
|
29
31
|
properties: {
|
|
30
32
|
id: { type: 'number', required: true, description: 'Opaque window id (HWND) from list_windows() or list_apps().' },
|
|
31
|
-
app: {
|
|
33
|
+
app: {
|
|
34
|
+
description: 'App metadata returned by DSH, or a Codex-style app identifier. Optional for id-only window bindings.',
|
|
35
|
+
oneOf: [
|
|
36
|
+
{ type: 'string' },
|
|
37
|
+
{ type: 'object', additionalProperties: true, properties: { id: { type: 'string', required: true } } },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
32
40
|
title: { type: 'string', description: 'Window title from the same window object; informational only.' },
|
|
33
41
|
},
|
|
34
42
|
additionalProperties: true,
|
|
@@ -53,10 +61,21 @@ function describeWindow(window) {
|
|
|
53
61
|
|
|
54
62
|
export function buildTools(config, deps) {
|
|
55
63
|
const { request, ctx, log, states } = deps;
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
const scopes = new WeakMap();
|
|
65
|
+
const createScope = () => ({ consentedApps: new Set(), audioApproved: false, lastActionAt: new Map(), states: new Map(), shots: new Map(), primaryShots: new Map(), invalidWindows: new Set(), observedWindows: new Set() });
|
|
66
|
+
const unscoped = { ...createScope(), states };
|
|
67
|
+
const scopeOf = exec => {
|
|
68
|
+
const session = exec?.agent?.session;
|
|
69
|
+
if (session === null || typeof session !== 'object') return unscoped;
|
|
70
|
+
if (!scopes.has(session)) scopes.set(session, createScope());
|
|
71
|
+
return scopes.get(session);
|
|
72
|
+
};
|
|
73
|
+
// A physical desktop has one pointer and focus. Serialize whole executions,
|
|
74
|
+
// including authorization, rather than just individual native RPC requests.
|
|
75
|
+
let queue = Promise.resolve();
|
|
76
|
+
const inputTools = new Set(['launch_app', 'click', 'press_key', 'type_text', 'scroll', 'drag',
|
|
77
|
+
'set_value', 'perform_secondary_action', 'activate_window', 'start_audio_recording']);
|
|
78
|
+
const observedInputs = new Set(['click', 'press_key', 'type_text', 'scroll', 'drag', 'set_value', 'perform_secondary_action']);
|
|
60
79
|
|
|
61
80
|
/**
|
|
62
81
|
* Codex asks the user to allow Computer Use per application before it drives
|
|
@@ -65,22 +84,49 @@ export function buildTools(config, deps) {
|
|
|
65
84
|
*/
|
|
66
85
|
async function ensureAppConsent(window, exec) {
|
|
67
86
|
if (config.appConsent === 'never') return;
|
|
87
|
+
const { consentedApps } = scopeOf(exec);
|
|
68
88
|
const app = window?.app ?? {};
|
|
69
89
|
const key = String(app.id ?? app.name ?? window?.id ?? 'unknown');
|
|
70
|
-
if (consentedApps.has(key))
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
if (!consentedApps.has(key)) {
|
|
91
|
+
await requestApproval(ctx, exec, {
|
|
92
|
+
toolName: toolName('get_window_state'),
|
|
93
|
+
reason: 'Computer Use is about to drive "' + (app.displayName ?? app.name ?? key) + '" (' + key + '). Approve this app for the session.',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
76
96
|
// Tell the helper, which enforces the same fact on its side (Codex does this
|
|
77
97
|
// through x-oai-cua-approved-app on every request).
|
|
78
|
-
|
|
98
|
+
// Re-send the grant so a restarted helper can recover without another prompt.
|
|
99
|
+
// Cache only after acknowledgement; a failed grant must remain a failed call.
|
|
100
|
+
await request('approve_app', { app: key }, callOpts(exec));
|
|
101
|
+
consentedApps.add(key);
|
|
79
102
|
}
|
|
80
|
-
const markAction = window => { if (window?.id !== undefined) lastActionAt.set(Number(window.id), Date.now()); };
|
|
103
|
+
const markAction = (window, exec) => { if (window?.id !== undefined) scopeOf(exec).lastActionAt.set(Number(window.id), Date.now()); };
|
|
81
104
|
const toolName = base => config.toolPrefix + base;
|
|
82
105
|
// The request budget is chosen per method in index.js (10s / 15s, Codex parity).
|
|
83
|
-
const callOpts = exec =>
|
|
106
|
+
const callOpts = exec => {
|
|
107
|
+
const turnToken = turnTokenOf(exec);
|
|
108
|
+
const scope = scopeOf(exec);
|
|
109
|
+
if (scope.turnToken !== turnToken) {
|
|
110
|
+
scope.states.clear();
|
|
111
|
+
scope.lastActionAt.clear();
|
|
112
|
+
scope.shots.clear();
|
|
113
|
+
scope.primaryShots.clear();
|
|
114
|
+
scope.turnToken = turnToken;
|
|
115
|
+
}
|
|
116
|
+
return { signal: exec?.signal, turnToken, session: exec?.agent?.session };
|
|
117
|
+
};
|
|
118
|
+
const coordinateParams = (params, window, exec) => {
|
|
119
|
+
const scope = scopeOf(exec);
|
|
120
|
+
const id = params.screenshotId ?? scope.primaryShots.get(Number(window.id));
|
|
121
|
+
const shot = scope.shots.get(id);
|
|
122
|
+
if (!shot || shot.windowId !== Number(window.id)) return params;
|
|
123
|
+
const mapped = { ...params };
|
|
124
|
+
for (const [key, scale] of [['x', shot.scaleX], ['y', shot.scaleY],
|
|
125
|
+
['from_x', shot.scaleX], ['to_x', shot.scaleX], ['from_y', shot.scaleY], ['to_y', shot.scaleY]]) {
|
|
126
|
+
if (typeof mapped[key] === 'number') mapped[key] = Math.floor(mapped[key] * scale);
|
|
127
|
+
}
|
|
128
|
+
return mapped;
|
|
129
|
+
};
|
|
84
130
|
|
|
85
131
|
/** Resolve + authorize a window before any action touches it. */
|
|
86
132
|
async function authorizeWindow(args, exec) {
|
|
@@ -97,7 +143,7 @@ export function buildTools(config, deps) {
|
|
|
97
143
|
|
|
98
144
|
async function gate(action, exec, { window, toolName: tool, label, reason }) {
|
|
99
145
|
assertTurnOpen(exec);
|
|
100
|
-
const state = window?.id !== undefined ? states.get(Number(window.id)) : undefined;
|
|
146
|
+
const state = window?.id !== undefined ? scopeOf(exec).states.get(Number(window.id)) : undefined;
|
|
101
147
|
const targetLabel = label ?? labelOfElement(state, action?.element_index);
|
|
102
148
|
if (needsApproval(config, { toolName: tool, label: targetLabel })) {
|
|
103
149
|
await requestApproval(ctx, exec, {
|
|
@@ -108,13 +154,48 @@ export function buildTools(config, deps) {
|
|
|
108
154
|
}
|
|
109
155
|
|
|
110
156
|
function action(name, description, parameters, run) {
|
|
157
|
+
const schema = compileParameters(parameters);
|
|
111
158
|
return {
|
|
112
159
|
name: toolName(name),
|
|
113
160
|
description,
|
|
114
|
-
parameters,
|
|
161
|
+
parameters: schema,
|
|
115
162
|
output: { schema: OUTPUT_SCHEMA, render: (_args, value) => [json(value)] },
|
|
116
163
|
isConcurrencySafe: () => false,
|
|
117
|
-
execute:
|
|
164
|
+
execute: (args, exec) => {
|
|
165
|
+
const execute = async () => {
|
|
166
|
+
assertArguments(schema, args);
|
|
167
|
+
exec?.signal?.throwIfAborted();
|
|
168
|
+
if (inputTools.has(name)) assertTurnOpen(exec);
|
|
169
|
+
const scope = scopeOf(exec);
|
|
170
|
+
const windowId = Number(args?.window?.id);
|
|
171
|
+
if (observedInputs.has(name) && scope.invalidWindows.has(windowId)) {
|
|
172
|
+
throw Object.assign(new Error('the previous input or observation failed; call get_window_state again and inspect the new state before retrying input'), { code: 'fresh_observation_required' });
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const value = await run(args, exec);
|
|
176
|
+
if (name === 'get_window_state') {
|
|
177
|
+
scope.invalidWindows.delete(windowId);
|
|
178
|
+
scope.observedWindows.add(windowId);
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (inputTools.has(name) || name === 'get_window_state') {
|
|
183
|
+
for (const id of scope.observedWindows) scope.invalidWindows.add(id);
|
|
184
|
+
scope.states.clear();
|
|
185
|
+
scope.shots.clear();
|
|
186
|
+
scope.primaryShots.clear();
|
|
187
|
+
if (Number.isFinite(windowId)) scope.invalidWindows.add(windowId);
|
|
188
|
+
if (!['cancelled_by_user', 'turn_stopped', 'turn_ended', 'cancelled', 'AbortError'].includes(error.code ?? error.name)) {
|
|
189
|
+
error.message += ' Re-observe with get_window_state before retrying; the previous input outcome may be unknown.';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const result = queue.then(execute, execute);
|
|
196
|
+
queue = result.catch(() => {});
|
|
197
|
+
return result;
|
|
198
|
+
},
|
|
118
199
|
};
|
|
119
200
|
}
|
|
120
201
|
|
|
@@ -183,10 +264,11 @@ export function buildTools(config, deps) {
|
|
|
183
264
|
if (!includeText && !includeShot) throw new Error('get_window_state must request include_text, include_screenshot, or both');
|
|
184
265
|
// Codex waits for the UI to settle before capturing after an action; the
|
|
185
266
|
// harness-side equivalent is a short settle window here.
|
|
186
|
-
const
|
|
267
|
+
const scope = scopeOf(exec);
|
|
268
|
+
const last = scope.lastActionAt.get(Number(window.id));
|
|
187
269
|
if (last !== undefined) {
|
|
188
270
|
const wait = Math.min(config.settleMs, config.settleMs - (Date.now() - last));
|
|
189
|
-
if (wait > 0) await
|
|
271
|
+
if (wait > 0) await delay(wait, undefined, { signal: exec?.signal });
|
|
190
272
|
}
|
|
191
273
|
const result = await request('get_window_state', {
|
|
192
274
|
window: { id: window.id },
|
|
@@ -196,7 +278,7 @@ export function buildTools(config, deps) {
|
|
|
196
278
|
ttlMs: config.snapshotTtlMs,
|
|
197
279
|
}, callOpts(exec));
|
|
198
280
|
if (result?.accessibility) {
|
|
199
|
-
states.set(Number(window.id), { ...result.accessibility, at: Date.now() });
|
|
281
|
+
scope.states.set(Number(window.id), { ...result.accessibility, at: Date.now() });
|
|
200
282
|
}
|
|
201
283
|
if (redacted) {
|
|
202
284
|
// appshot guard: never let these pixels leave the machine
|
|
@@ -204,7 +286,21 @@ export function buildTools(config, deps) {
|
|
|
204
286
|
result.redactedNote = 'screenshots are disabled for this application by the redactApps policy';
|
|
205
287
|
result.screenshots = [];
|
|
206
288
|
} else if (Array.isArray(result?.screenshots)) {
|
|
289
|
+
// Attachment normalization can resize a captured window. The model aims
|
|
290
|
+
// in the stored image, while the helper owns the original capture pixels.
|
|
291
|
+
for (const [id, shot] of scope.shots) {
|
|
292
|
+
if (shot.windowId === Number(window.id)) scope.shots.delete(id);
|
|
293
|
+
}
|
|
207
294
|
const attached = await attachScreenshots(ctx, result.screenshots, window);
|
|
295
|
+
for (let index = 0; index < attached.length; index++) {
|
|
296
|
+
const source = result.screenshots[index];
|
|
297
|
+
const stored = attached[index];
|
|
298
|
+
if (source.width > 0 && source.height > 0 && stored.width > 0 && stored.height > 0) {
|
|
299
|
+
scope.shots.set(stored.id, { windowId: Number(window.id), scaleX: source.width / stored.width, scaleY: source.height / stored.height });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
scope.primaryShots.delete(Number(window.id));
|
|
303
|
+
if (attached.length) scope.primaryShots.set(Number(window.id), attached[0].id);
|
|
208
304
|
result.screenshots = attached;
|
|
209
305
|
}
|
|
210
306
|
return result;
|
|
@@ -254,8 +350,8 @@ export function buildTools(config, deps) {
|
|
|
254
350
|
params.screenshotId = args.screenshotId;
|
|
255
351
|
await gate(params, exec, { window, toolName: 'click' });
|
|
256
352
|
if (config.dryRun) return { dryRun: true, wouldClick: params };
|
|
257
|
-
const clicked = await request('click', stripUndefined(params), callOpts(exec));
|
|
258
|
-
markAction(window);
|
|
353
|
+
const clicked = await request('click', stripUndefined(coordinateParams(params, window, exec)), callOpts(exec));
|
|
354
|
+
markAction(window, exec);
|
|
259
355
|
return clicked;
|
|
260
356
|
},
|
|
261
357
|
),
|
|
@@ -275,7 +371,7 @@ export function buildTools(config, deps) {
|
|
|
275
371
|
await gate({}, exec, { window, toolName: 'press_key', label: 'key ' + args.key, reason: undefined });
|
|
276
372
|
if (config.dryRun) return { dryRun: true, wouldPress: args.key };
|
|
277
373
|
const pressed = await request('press_key', { window: { id: window.id }, key: args.key }, callOpts(exec));
|
|
278
|
-
markAction(window);
|
|
374
|
+
markAction(window, exec);
|
|
279
375
|
return pressed;
|
|
280
376
|
},
|
|
281
377
|
));
|
|
@@ -292,7 +388,7 @@ export function buildTools(config, deps) {
|
|
|
292
388
|
await gate({}, exec, { window, toolName: 'type_text', label: 'text ' + String(args.text).slice(0, 40) });
|
|
293
389
|
if (config.dryRun) return { dryRun: true, wouldType: args.text };
|
|
294
390
|
const typed = await request('type_text', { window: { id: window.id }, text: String(args.text) }, callOpts(exec));
|
|
295
|
-
markAction(window);
|
|
391
|
+
markAction(window, exec);
|
|
296
392
|
return typed;
|
|
297
393
|
},
|
|
298
394
|
));
|
|
@@ -320,8 +416,8 @@ export function buildTools(config, deps) {
|
|
|
320
416
|
});
|
|
321
417
|
await gate(params, exec, { window, toolName: 'scroll' });
|
|
322
418
|
if (config.dryRun) return { dryRun: true, wouldScroll: params };
|
|
323
|
-
const scrolled = await request('scroll', params, callOpts(exec));
|
|
324
|
-
markAction(window);
|
|
419
|
+
const scrolled = await request('scroll', coordinateParams(params, window, exec), callOpts(exec));
|
|
420
|
+
markAction(window, exec);
|
|
325
421
|
return scrolled;
|
|
326
422
|
},
|
|
327
423
|
));
|
|
@@ -347,8 +443,8 @@ export function buildTools(config, deps) {
|
|
|
347
443
|
};
|
|
348
444
|
await gate(params, exec, { window, toolName: 'drag' });
|
|
349
445
|
if (config.dryRun) return { dryRun: true, wouldDrag: params };
|
|
350
|
-
const dragged = await request('drag', params, callOpts(exec));
|
|
351
|
-
markAction(window);
|
|
446
|
+
const dragged = await request('drag', stripUndefined(coordinateParams(params, window, exec)), callOpts(exec));
|
|
447
|
+
markAction(window, exec);
|
|
352
448
|
return dragged;
|
|
353
449
|
},
|
|
354
450
|
));
|
|
@@ -367,7 +463,7 @@ export function buildTools(config, deps) {
|
|
|
367
463
|
await gate(params, exec, { window, toolName: 'set_value' });
|
|
368
464
|
if (config.dryRun) return { dryRun: true, wouldSetValue: params };
|
|
369
465
|
const setValue = await request('set_value', params, callOpts(exec));
|
|
370
|
-
markAction(window);
|
|
466
|
+
markAction(window, exec);
|
|
371
467
|
return setValue;
|
|
372
468
|
},
|
|
373
469
|
));
|
|
@@ -386,7 +482,7 @@ export function buildTools(config, deps) {
|
|
|
386
482
|
await gate(params, exec, { window, toolName: 'perform_secondary_action' });
|
|
387
483
|
if (config.dryRun) return { dryRun: true, wouldPerform: params };
|
|
388
484
|
const performed = await request('perform_secondary_action', params, callOpts(exec));
|
|
389
|
-
markAction(window);
|
|
485
|
+
markAction(window, exec);
|
|
390
486
|
return performed;
|
|
391
487
|
},
|
|
392
488
|
));
|
|
@@ -410,12 +506,13 @@ export function buildTools(config, deps) {
|
|
|
410
506
|
if (!config.audio) throw new Error('audio recording is disabled for this plugin: set audio: true in the plugin config (or DSH_CU_AUDIO=1) and restart the host');
|
|
411
507
|
// Codex elicits "Allow Computer Use to record computer audio?" before the first
|
|
412
508
|
// recording of a session, separate from the per-app consent it asks for windows.
|
|
413
|
-
|
|
509
|
+
const scope = scopeOf(exec);
|
|
510
|
+
if (!scope.audioApproved) {
|
|
414
511
|
await requestApproval(ctx, exec, {
|
|
415
512
|
toolName: toolName('start_audio_recording'),
|
|
416
513
|
reason: 'Allow Computer Use to record computer audio?',
|
|
417
514
|
});
|
|
418
|
-
audioApproved = true;
|
|
515
|
+
scope.audioApproved = true;
|
|
419
516
|
}
|
|
420
517
|
return request('start_audio_recording', {
|
|
421
518
|
maxDurationMs: Number(args.max_duration_ms ?? 60000),
|
|
@@ -448,6 +545,8 @@ export function buildTools(config, deps) {
|
|
|
448
545
|
return {
|
|
449
546
|
ok: true,
|
|
450
547
|
platform: ping?.platform ?? 'unknown',
|
|
548
|
+
pid: ping?.pid ?? null,
|
|
549
|
+
overlayPid: ping?.overlayPid ?? null,
|
|
451
550
|
helper: deps.describeHelper?.() ?? null,
|
|
452
551
|
windows,
|
|
453
552
|
policy: {
|