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.
@@ -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 output = execFileSync(compiler, args, { encoding: 'utf8', windowsHide: true });
82
- if (!fs.existsSync(exe)) throw new Error('helper compilation produced no executable: ' + output);
83
- return { exe, built: true };
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, HELPER_EXE, OVERLAY_EXE } from './build-helper.mjs';
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') + ' -> ' + HELPER_EXE);
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
- let bridge = null;
56
- let starting = null;
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
- const active = await getBridge();
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
- try {
89
- await active.request('configure', { requireAppApproval: config.appConsent !== 'never' }, { timeoutMs: config.timeoutMs });
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
- try { await active.request('end_turn', {}, { timeoutMs: 5000 }); } catch { /* best effort */ }
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
- return active.request(method, params, { ...opts, timeoutMs });
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: exePath,
111
- exists: fs.existsSync(exePath),
112
- fresh: isHelperFresh(exePath),
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('dispose', () => {
146
- try { bridge?.dispose(); } catch { /* already gone */ }
147
- bridge = null;
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
- // The user's session preset may disable prompting entirely ("approval: never",
167
- // e.g. the full-access preset). Honour that instead of failing every action:
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: { type: 'string', required: true, description: 'App identifier carried forward from the same window object (required by the window2 contract).' },
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 lastActionAt = new Map();
57
- const consentedApps = new Set();
58
- /** Codex treats computer audio as its own app ("computer-audio") and asks once. */
59
- let audioApproved = false;
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)) return;
71
- await requestApproval(ctx, exec, {
72
- toolName: toolName('get_window_state'),
73
- reason: 'Computer Use is about to drive "' + (app.displayName ?? app.name ?? key) + '" (' + key + '). Approve this app for the session.',
74
- });
75
- consentedApps.add(key);
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
- try { await request('approve_app', { app: key }, callOpts(exec)); } catch { /* enforcement is best effort */ }
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 => ({ signal: exec?.signal, turnToken: turnTokenOf(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: run,
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 last = lastActionAt.get(Number(window.id));
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 new Promise(resolve => setTimeout(resolve, wait));
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
- if (!audioApproved) {
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: {