nexus-windows-codex 3.0.1 → 3.1.1

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 CHANGED
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.1.1] - 2026-10-24 — Numbers on the image
11
+
12
+ ### Added
13
+ - `look(marks="auto"|"all")` (mode both, native tree): the tree is read just before the capture, once, under the same
14
+ lock; each kept element gets its exact tree line number drawn on the image (digits only, built-in 5×7 font ×2, thin
15
+ outline), after scaling and before encoding, so at a constant size in the final image. `auto`: unnamed controls with
16
+ a rectangle; `all`: every element with a rectangle. Default `none`.
17
+ - Guards: the window frame is read before and after the tree and after PrintWindow; if it moved or was resized, no
18
+ number and the reason is given. Elements of another window (popup menu absent from PrintWindow) are never labelled;
19
+ a blank capture is judged before any drawing; a label that does not fit whole is omitted, never cut; labels without
20
+ room, past 120, or past 400 eligible elements are counted in the report.
21
+
22
+ ### Fixed
23
+ - A zoom region computed on one window's image, after a dialog has been followed, is refused before any capture
24
+ (before, the parent's area was read in the dialog).
25
+ - A refused or failed `look` no longer leaves the previous observation usable by `act`.
26
+ - Live test of the foreground fallback: it had never run (always skipped while the user was active) and failed as
27
+ soon as it ran, 3.0.1 included, because it required `focused` after the foreground was given back. It is now judged
28
+ by the fixture's focus counter, the technique and the give-back, with no postcondition.
29
+
30
+ ### Security
31
+ - Reviewed by the Codex agent over three rounds (cut label reported as drawn, window resized during PrintWindow, zoom
32
+ region applied to a followed dialog, stale observation after a refused look, silent 400 cap).
33
+
34
+ ## [3.1.0] - 2026-10-24 — Tk in the background, common image rule, zoom
35
+
36
+ ### Added
37
+ - Tk in the background, window covered: a click at a point is sent with the mouse button marked pressed for Tk's own
38
+ thread only (press and release owned by a helper thread); lists scroll through their native scroll bar, read back
39
+ (recipe shared with NexusWindows for Claude).
40
+ - `look(region=[x1, y1, x2, y2])`: an area of the last full native image, read at full resolution and enlarged up to
41
+ 2x, within the same caps; the zoomed image has its own frame, so its points work with its own `screenshotId`, while
42
+ the move guard keeps checking the whole window frame. An area outside the window is refused.
43
+ - No duplicate image: same WebP bytes (deterministic encoder, so same pixels), size and frame as the last image sent for
44
+ that window or zoom: not attached again; the reply names the id that takes points; `mode="image"` always attaches it.
45
+ The zoom reference only follows images the model actually received.
46
+ - `launch` of a Chromium/Electron executable by its path: started with the switches that keep it drawn while covered.
47
+
48
+ ### Changed
49
+ - Native images follow the common Nexus image rule: at most 1568 px and 1.15 MP (area-average reduction), lossless
50
+ WebP from a built-in VP8L encoder (no dependency; checked pixel-exact against libwebp), near-lossless above 200 KB,
51
+ never over 500 KB. A point on a reduced image is scaled back to the exact screen pixel, the same one the guards check.
52
+
53
+ ### Fixed
54
+ - Outcome classification: once any input may have left, a failure is an unknown outcome (never replayed, no fallback,
55
+ accord consumed); a refusal raised before any input (applicability checks, the foreground token wait, the user's
56
+ pause, the emergency stop, a COM refusal before any pattern call, Chromium before its first activation message) is
57
+ "nothing sent", so the planned fallback stays available.
58
+ - The emergency stop is checked between Tk scroll lines and is never masked by the end-of-scroll cleanup.
59
+
60
+ ### Security
61
+ - Reviewed by the Codex agent over many rounds (partial input, held press ownership, frames of zoomed images, outcome
62
+ classification before and after input).
63
+
10
64
  ## [3.0.1] - 2026-10-23 — Family naming, release with provenance
11
65
 
12
66
  ### Changed
package/README.md CHANGED
@@ -101,7 +101,7 @@ the JavaScript module cache does not reload.
101
101
  | Tool | Purpose |
102
102
  |---|---|
103
103
  | `windows(filter)` | Application windows (id, app, title, pid), like the taskbar; password managers never listed |
104
- | `look(window, mode)` | One window by id or a unique part of its title or app: UI Automation tree, plus a capture that works on covered windows with `mode="image"` or `"both"`; selects the window for `act` |
104
+ | `look(window, mode, region, marks)` | One window by id or a unique part of its title or app: UI Automation tree, plus a capture that works on covered windows with `mode="image"` or `"both"`; `region` zooms on an area of the last image; `marks` draws tree line numbers on the image; selects the window for `act` |
105
105
  | `act(steps)` | One step, or up to 32 each with its own `expect`, on the window of the last `look`; stops at the first step not verified |
106
106
  | `launch(app)` | Start an application (name, path, Store app id), a folder, a document or a Settings page (`ms-settings:`) and give the foreground back |
107
107
  | `window(action, window)` | `action="close"`: one close message, read back; a "save?" prompt is reported, never answered |
@@ -195,9 +195,17 @@ its option element.
195
195
  `observe({mode:'image', capture:'native'})` captures the bound window with `PrintWindow(PW_RENDERFULLCONTENT)`
196
196
  without bringing it forward, even when covered: per-monitor DPI aware, cropped to the DWM frame, DPI-unaware windows
197
197
  drawn at their logical size and scaled. In the Codex skill, a blank, minimized or failed capture falls back to Sky's
198
- screenshot; the MCP server has no such fallback and reports the failure. Native images are local PNG files (32 files
199
- at most, removed after 10 minutes at the next capture, at server start and at a clean stop): evidence for the model,
200
- and the coordinate frame of a point action that names the image (`screenshotId`).
198
+ screenshot; the MCP server has no such fallback and reports the failure. Native images follow the common Nexus image
199
+ rule: at most 1568 px and 1.15 MP, lossless WebP from a built-in encoder (near-lossless above 200 KB, never over
200
+ 500 KB), local files (32 at most, removed after 10 minutes at the next capture, at server start and at a clean stop):
201
+ evidence for the model, and the coordinate frame of a point action that names the image (`screenshotId`), scaled back
202
+ to the screen pixel. `look(region=[x1, y1, x2, y2])` reads an area of the last full image at full resolution, enlarged
203
+ up to 2x for small text; its own `screenshotId` takes points. An image identical to the last one sent for that window
204
+ (same pixels and frame) is not attached again; the reply names the id that takes points, and `mode="image"` always
205
+ attaches it. `look(mode="both", marks="auto"|"all")` reads the tree just before the capture and draws on the image the
206
+ tree line number of each kept element (`auto`: unnamed controls; `all`: every element with a rectangle), at a constant
207
+ size after scaling; no number when the window moved between the two reads, on another window's popup, or when the
208
+ label does not fit whole. Default `none`. Numbers link the image to the tree; `act` still targets a selector or a point.
201
209
 
202
210
  ### Verified batches (`actBatch`)
203
211
 
@@ -205,10 +205,25 @@ export function elementsAt(elements, x, y) {
205
205
  return w > 0 && h > 0 && x >= ex && y >= ey && x < ex + w && y < ey + h;
206
206
  });
207
207
  }
208
+ // Point d'une capture native → pixel écran ENTIER, celui que l'entrée vise (session) et que la confirmation contrôle
209
+ // (screenPoints) : une seule conversion, arrondie au centre de pixel et bornée au cadre (relecture de Codex du 24/10).
210
+ export function nativeScreen(shot, point) {
211
+ const f = shot.frame;
212
+ const sx = shot.width > 0 ? f.width / shot.width : 1;
213
+ const sy = shot.height > 0 ? f.height / shot.height : 1;
214
+ return {
215
+ x: Math.max(f.left, Math.min(f.left + f.width - 1, Math.round(f.left + (point.x + 0.5) * sx - 0.5))),
216
+ y: Math.max(f.top, Math.min(f.top + f.height - 1, Math.round(f.top + (point.y + 0.5) * sy - 0.5))),
217
+ };
218
+ }
208
219
  // Points écran possibles d'un point de capture Sky : origine de la capture + point, ou point ramené à la taille de la
209
220
  // fenêtre (capture mise à l'échelle). Les deux sont contrôlés : le doute ne laisse rien passer.
210
221
  export function screenPoints(point, screenshot, root) {
211
222
  const out = [];
223
+ if (screenshot?.native && screenshot.frame) { // capture native éventuellement réduite : le pixel même de l'entrée
224
+ const at = nativeScreen(screenshot, point);
225
+ out.push([at.x, at.y]);
226
+ }
212
227
  if (Number.isFinite(screenshot?.originX) && Number.isFinite(screenshot?.originY)) out.push([screenshot.originX + point.x, screenshot.originY + point.y]);
213
228
  const [rx, ry, rw, rh] = root?.bounds ?? [];
214
229
  if (rw > 0 && rh > 0 && screenshot?.width > 0 && screenshot?.height > 0) {
package/lib/mcp.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // sans Sky, exposée en outils. Aucune dépendance npm : le protocole utile (initialize, ping, tools/list, tools/call)
3
3
  // tient ici, et la chaîne d'approvisionnement reste vide comme pour le reste de l'outil.
4
4
  import { readFile } from 'node:fs/promises';
5
+ import { createHash } from 'node:crypto';
5
6
  import { summarize } from './session.mjs';
6
7
 
7
8
  export const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
@@ -42,6 +43,10 @@ const ACTION = {
42
43
  },
43
44
  };
44
45
  const OBSERVE_MODE = { type: 'string', enum: ['text', 'image', 'both'], description: 'text (UI Automation tree, default), image (native capture), both' };
46
+ const REGION = { type: 'array', items: { type: 'number' }, minItems: 4, maxItems: 4,
47
+ description: '[x1, y1, x2, y2] in pixels of the last native image of this window: that area enlarged up to 2x (small text); its own screenshotId takes points' };
48
+ const NUMBERS = { type: 'string', enum: ['none', 'auto', 'all'],
49
+ description: 'Numbers drawn on the image of mode both, each the index of its tree line: none (default), auto (unnamed controls), all' };
45
50
  const WINDOW = { type: ['integer', 'string'], description: 'Window id from windows, or a unique part of its title or app; omitted: the window of the last look' };
46
51
  const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
47
52
  const WRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
@@ -54,9 +59,10 @@ export const TOOLS = [
54
59
  inputSchema: { type: 'object', additionalProperties: false, properties: { filter: { type: 'string' } } } },
55
60
  { name: 'look', title: 'Look at a window', annotations: { ...READ, title: 'Look at a window' },
56
61
  description: 'See a window in the background, without bringing it forward: UI Automation tree (roles, names, ids, values, states) and, ' +
57
- 'with mode image or both, a native capture that works even when the window is covered. Selects that window for act; ' +
62
+ 'with mode image or both, a native capture that works even when the window is covered (WebP, at most 1568 px; ' +
63
+ 'unchanged since the last image sent: not attached again, mode image always attaches it). Selects that window for act; ' +
58
64
  'an observation serves one act and expires after 60 s.',
59
- inputSchema: { type: 'object', additionalProperties: false, properties: { window: WINDOW, mode: OBSERVE_MODE } } },
65
+ inputSchema: { type: 'object', additionalProperties: false, properties: { window: WINDOW, mode: OBSERVE_MODE, region: REGION, marks: NUMBERS } } },
60
66
  { name: 'act', title: 'Act in the window', annotations: { ...WRITE, title: 'Act in the window' },
61
67
  description: 'Steps on the window of the last look, in the background first (Win32 messages, UI Automation, DevTools for Chromium), ' +
62
68
  'the real mouse/keyboard only as a fallback under the shared foreground token. One step, or up to 32 each with its own expect; ' +
@@ -91,17 +97,62 @@ function errorStatus(error) {
91
97
  }
92
98
 
93
99
  // Observation lisible par le modèle : arbre et état, plus l'image native jointe (fichier local, jamais copiée ailleurs).
94
- async function observationContent(observation) {
100
+ // Pas d'image en double (règle commune des produits Nexus, shared/images.md) : mêmes octets (encodeur WebP déterministe :
101
+ // mêmes pixels), mêmes dimensions et même cadre écran que la dernière image envoyée pour cette fenêtre (ou ce zoom) →
102
+ // non jointe, la réponse dit quel identifiant prend les points. `always` (mode image) la joint toujours. `received` :
103
+ // appelé pour chaque image que le modèle a réellement (jointe, ou identique à la dernière envoyée), jamais pour une
104
+ // image illisible (purgée) qui ne part pas.
105
+ async function observationContent(observation, sent = null, always = false, received = null) {
95
106
  const summary = summarize(observation);
96
107
  const w = observation.window ?? {};
97
108
  const content = [text(`window ${w.id} "${w.title ?? ''}" (${w.app ?? '?'}) · ${observation.elements?.length ?? 0} elements · ${observation.source}`), text(summary)];
98
109
  for (const image of observation.images ?? []) {
99
- try { content.push({ type: 'image', data: (await readFile(image.path)).toString('base64'), mimeType: 'image/png' }); }
100
- catch { /* capture purgée entre-temps : l'arbre reste */ }
110
+ const mimeType = image.path.endsWith('.webp') ? 'image/webp' : 'image/png';
111
+ let data;
112
+ try { data = await readFile(image.path); }
113
+ catch { continue; /* capture purgée entre-temps : l'arbre reste */ }
114
+ const key = `${w.id}|${image.zoom ? 'zoom' : 'full'}`;
115
+ const print = createHash('sha256').update(JSON.stringify([image.width, image.height, image.frame])).update(data).digest('hex');
116
+ const last = sent?.get(key);
117
+ if (!always && last?.print === print) {
118
+ content.push(text(`Image ${image.id} unchanged since ${last.id} (same pixels and frame): not attached again; points take screenshotId ${image.id} (mode image attaches it).`));
119
+ received?.(image, w.id);
120
+ continue;
121
+ }
122
+ sent?.set(key, { print, id: image.id });
123
+ content.push({ type: 'image', data: data.toString('base64'), mimeType });
124
+ if (image.marks) content.push(text(numbersLine(image)));
125
+ received?.(image, w.id);
101
126
  }
102
127
  return content;
103
128
  }
104
129
 
130
+ // Numéros dessinés sur une image jointe : lesquels, et ceux qui manquent (place, autre fenêtre, hors zone).
131
+ function numbersLine(image) {
132
+ const m = image.marks;
133
+ if (m.reason) return `Image ${image.id}: ${m.reason}.`;
134
+ const parts = [`Image ${image.id}: numbers = tree line indexes, drawn for ${m.drawn?.length ?? 0} element(s)`];
135
+ if (m.omitted?.length) parts.push(`not drawn for lack of room: ${m.omitted.join(', ')}`);
136
+ if (m.foreign?.length) parts.push(`other windows (not in this capture): ${m.foreign.join(', ')}`);
137
+ if (m.outside) parts.push(`${m.outside} outside the captured area`);
138
+ if (m.capped) parts.push(`${m.capped} more element(s) not considered (limit 400)`);
139
+ return parts.join('; ') + '.';
140
+ }
141
+
142
+ // Zone [x1, y1, x2, y2] (pixels inclus) d'une image native → zone écran [left, top, width, height] : bords des pixels de
143
+ // l'image ramenés au cadre (même échelle que les points), bornés au cadre.
144
+ export function regionToScreen(image, region) {
145
+ if (!image?.frame || !(image.width > 0) || !(image.height > 0) || !Array.isArray(region) || region.length !== 4 || !region.every(Number.isFinite)) {
146
+ throw new Error('region needs [x1, y1, x2, y2] and a native image of this window (look with mode image or both first)');
147
+ }
148
+ const f = image.frame, sx = f.width / image.width, sy = f.height / image.height;
149
+ const [x1, y1, x2, y2] = region;
150
+ const left = Math.max(0, Math.floor(Math.min(x1, x2) * sx)), top = Math.max(0, Math.floor(Math.min(y1, y2) * sy));
151
+ const right = Math.min(f.width, Math.ceil((Math.max(x1, x2) + 1) * sx)), bottom = Math.min(f.height, Math.ceil((Math.max(y1, y2) + 1) * sy));
152
+ if (right - left < 2 || bottom - top < 2) throw new Error('region is empty or outside the image');
153
+ return [f.left + left, f.top + top, right - left, bottom - top];
154
+ }
155
+
105
156
  function stepLine(step) {
106
157
  const how = [step.technique, step.foregroundTaken ? 'foreground taken' : null].filter(Boolean).join(', ');
107
158
  return `${mark(step.status)} ${step.kind} — ${step.status}${how ? ` (${how})` : ''}${step.error ? `: ${step.error}` : ''}`;
@@ -109,6 +160,16 @@ function stepLine(step) {
109
160
 
110
161
  export class WindowsTools {
111
162
  #session; #observation = null; #bound = null;
163
+ #sent = new Map(); // fenêtre|full ou zoom → dernière image envoyée (empreinte, id)
164
+ #images = new Map(); // fenêtre observée → dernière image native complète envoyée (repère des zones de zoom)
165
+ #observed = new Map(); // fenêtre liée → fenêtre réellement observée (dialogue suivi)
166
+ // Toute observation renvoyée (look, act avec refresh) : fenêtre réellement observée ; son image complète devient le
167
+ // repère des régions seulement si le modèle l'a reçue (#received, relecture de Codex du 24/10).
168
+ #remember(observation) {
169
+ const id = observation?.window?.id;
170
+ if (id !== undefined && this.#bound?.id !== undefined) this.#observed.set(this.#bound.id, id);
171
+ }
172
+ #received = (image, windowId) => { if (!image.zoom && windowId !== undefined) this.#images.set(windowId, image); };
112
173
  constructor(session) { this.#session = session; }
113
174
  async #choose(window) {
114
175
  const listed = await this.#session.windows();
@@ -148,9 +209,15 @@ export class WindowsTools {
148
209
  return [text(wanted ? listed.filter(w => w.title.toLowerCase().includes(wanted) || w.app.toLowerCase().includes(wanted)) : listed)];
149
210
  }
150
211
  case 'look': {
212
+ this.#observation = null; // refus ou échec : plus d'observation ancienne pour act (relecture de Codex du 24/10)
151
213
  await this.#bind(args.window);
152
- this.#observation = await s.observe({ mode: args.mode ?? 'text', backend: 'native', capture: 'native' });
153
- return observationContent(this.#observation);
214
+ // Repère : dernière image complète envoyée de la fenêtre que cette liaison observe (dialogue suivi compris).
215
+ const shown = this.#observed.get(this.#bound?.id) ?? this.#bound?.id;
216
+ const zoom = args.region === undefined ? null : regionToScreen(this.#images.get(shown), args.region);
217
+ const mode = zoom && (args.mode ?? 'text') === 'text' ? 'both' : args.mode ?? 'text';
218
+ this.#observation = await s.observe({ mode, backend: 'native', capture: 'native', zoom, ...(zoom ? { zoomWindow: shown } : {}), ...(args.marks && args.marks !== 'none' ? { marks: args.marks } : {}) });
219
+ this.#remember(this.#observation);
220
+ return observationContent(this.#observation, this.#sent, mode === 'image', this.#received);
154
221
  }
155
222
  case 'act': {
156
223
  if (!this.#observation) throw new Error('Look at the window first (look)');
@@ -172,7 +239,10 @@ export class WindowsTools {
172
239
  const lines = report.steps.map(stepLine).join('\n');
173
240
  const content = [text(lines + (this.#observation ? '' : '\nNo fresh observation: look again before the next act.'))];
174
241
  // Observation rafraîchie : arbre et, demandée par refresh image|both, l'image native jointe comme pour look.
175
- if (report.observation) content.push(...(await observationContent(report.observation)).slice(1));
242
+ if (report.observation) {
243
+ this.#remember(report.observation);
244
+ content.push(...(await observationContent(report.observation, this.#sent, false, this.#received)).slice(1));
245
+ }
176
246
  return content;
177
247
  }
178
248
  case 'launch': {
package/lib/native.mjs CHANGED
@@ -5,24 +5,30 @@ import { policyPath } from './garde-fous.mjs';
5
5
 
6
6
  // Arrêt d'urgence rapporté par le helper : avant toute entrée (refus, rien d'envoyé), ou APRÈS une entrée partielle
7
7
  // (« Emergency stop after input: k of n characters sent ») : issue inconnue, accord consommé, jamais rejoué.
8
- export const stopRefusal = (message, error) => {
8
+ export const stopRefusal = (message, error, inputStarted = false) => {
9
9
  if (error !== 'EmergencyStopException') return null;
10
10
  const partial = /^Emergency stop after input: (\d+) of (\d+) characters sent/.exec(message ?? '');
11
- return Object.assign(new Error(message), { name: 'EmergencyStopError', emergencyStop: true, deferred: !partial,
12
- ...(partial ? { afterInput: true, typed: { characters: Number(partial[1]), total: Number(partial[2]) } } : {}) });
11
+ const after = Boolean(partial) || inputStarted === true; // helper : entrée déjà partie (inputStarted)
12
+ return Object.assign(new Error(message), { name: 'EmergencyStopError', emergencyStop: true, deferred: !after,
13
+ ...(after ? { afterInput: true } : {}),
14
+ ...(partial ? { typed: { characters: Number(partial[1]), total: Number(partial[2]) } } : {}) });
13
15
  };
14
16
 
15
17
  // Erreurs du helper. « notApplicable » : aucune entrée envoyée, la technique suivante est permise. « refused » : aucune
16
18
  // entrée envoyée et aucun repli (mot de passe, application exclue, identité changée, fenêtre fermée, cible désactivée).
17
19
  // « julienActive » : Julien a repris clavier ou souris pendant l'attente du jeton, rien n'a été envoyé. Toute autre
18
20
  // erreur d'action peut suivre une entrée : résultat inconnu, jamais rejoué.
19
- function nativeError({ message, error }) {
21
+ // inputStarted (helper) : une entrée est déjà partie ; aucune de ces natures « rien n'a été envoyé » ne s'applique plus,
22
+ // l'issue est inconnue et terminale (relecture de Codex du 24/10 : sinon un repli physique rejouait le geste).
23
+ function nativeError({ message, error, inputStarted }) {
24
+ const after = inputStarted === true;
20
25
  return Object.assign(new Error(message || 'Native UIA failed'), {
21
26
  nativeError: error ?? null,
22
- deferred: error === 'TimeoutException' || error === 'JulienActiveException',
23
- julienActive: error === 'JulienActiveException',
24
- notApplicable: error === 'BackgroundNotApplicableException' || error === 'CaptureUnavailableException',
25
- refused: error === 'ActionRefusedException',
27
+ deferred: !after && (error === 'TimeoutException' || error === 'JulienActiveException'),
28
+ julienActive: !after && error === 'JulienActiveException',
29
+ notApplicable: !after && (error === 'BackgroundNotApplicableException' || error === 'CaptureUnavailableException'),
30
+ refused: !after && error === 'ActionRefusedException',
31
+ ...(after ? { afterInput: true } : {}),
26
32
  });
27
33
  }
28
34
 
@@ -103,7 +109,7 @@ export class NativeUIA {
103
109
  this.#idle = setTimeout(() => this.close(), this.idleTimeoutMs);
104
110
  this.#idle.unref();
105
111
  if (response.result.ok) pending.resolve(response.result);
106
- else pending.reject(stopRefusal(response.result.message, response.result.error) ?? nativeError(response.result));
112
+ else pending.reject(stopRefusal(response.result.message, response.result.error, response.result.inputStarted) ?? nativeError(response.result));
107
113
  } catch (error) { fail(error); return; }
108
114
  }
109
115
  });
@@ -132,7 +138,8 @@ export class NativeUIA {
132
138
  }
133
139
  inspect(window) { return this.call(window, 'inspect'); }
134
140
  // Lectures sans entrée (permises pendant l'arrêt d'urgence).
135
- capture(window, directory) { return this.call(window, 'capture', { directory }); }
141
+ // marks : numéros à dessiner ({ marks: [{index, bounds, scope}], treeFrame }), lus avec l'arbre juste avant (session.mjs).
142
+ capture(window, directory, zoom, marks = null) { return this.call(window, 'capture', { directory, ...(zoom ? { zoom } : {}), ...(marks ?? {}) }); }
136
143
  related(window) { return this.call(window, 'related'); }
137
144
  alive(window) { return this.call(window, 'alive'); }
138
145
  // Page des Paramètres (ms-settings: seul), ouverte sous le jeton et premier plan rendu ; rend la fenêtre notée.
package/lib/session.mjs CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, writeFile, rename, stat, unlink, readdir } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { ApplicationMemory } from './memory.mjs';
5
- import { parseTree, uniqueElement, findElements, structureHash, stateDigest, checkExpectation, validateExpectation, digest } from './tree.mjs';
5
+ import { parseTree, markCandidates, uniqueElement, findElements, structureHash, stateDigest, checkExpectation, validateExpectation, digest } from './tree.mjs';
6
6
  import { emergencyStop } from './stop.mjs';
7
7
  import { JulienActiveError, PresenceUnknownError, QUIET_MS, MAX_WAIT_MS } from './presence.mjs';
8
8
  import * as gardeFous from './garde-fous.mjs';
@@ -105,7 +105,10 @@ const actionFields = {
105
105
  function nativeShot(observation, id) {
106
106
  const image = (observation.images ?? []).find(i => i.id === id);
107
107
  if (!image?.frame) return undefined;
108
- return { id, width: image.width, height: image.height, originX: image.frame.left, originY: image.frame.top, frame: image.frame, native: true };
108
+ // frame : zone que couvre l'image (points) ; windowFrame : cadre entier de la fenêtre (garde de mouvement du helper),
109
+ // distincts pour une image zoomée (relecture de Codex du 24/10).
110
+ return { id, width: image.width, height: image.height, originX: image.frame.left, originY: image.frame.top, frame: image.frame,
111
+ windowFrame: image.windowFrame ?? image.frame, native: true };
109
112
  }
110
113
  function validateAction(spec) {
111
114
  if (!spec || typeof spec !== 'object' || Array.isArray(spec) || !Object.hasOwn(actionFields, spec.kind)) throw new Error('Unsupported action');
@@ -525,7 +528,7 @@ export class WindowsSession {
525
528
  }
526
529
  async #pruneCaptures(directory) {
527
530
  let files;
528
- try { files = (await readdir(directory)).filter(f => f.endsWith('.png')); } catch { return; }
531
+ try { files = (await readdir(directory)).filter(f => f.endsWith('.png') || f.endsWith('.webp')); } catch { return; }
529
532
  const dated = [];
530
533
  for (const name of files) {
531
534
  try { dated.push({ file: path.join(directory, name), at: (await stat(path.join(directory, name))).mtimeMs }); } catch { /* déjà retiré */ }
@@ -536,12 +539,13 @@ export class WindowsSession {
536
539
  if (i >= CAPTURE_KEEP || now - entry.at > CAPTURE_RETENTION_MS) await unlink(entry.file).catch(() => {});
537
540
  }
538
541
  }
539
- async #capture(mode, backend = 'auto', { capture = 'sky', windowChange = null } = {}) {
542
+ async #capture(mode, backend = 'auto', { capture = 'sky', windowChange = null, zoom = null, marks = 'none' } = {}) {
540
543
  this.#invalidate();
541
544
  if (!this.#window) throw new Error('Bind a returned window before observing');
542
545
  if (!['text', 'image', 'both'].includes(mode)) throw new Error('mode must be text, image or both');
543
546
  if (!['auto', 'sky', 'native'].includes(backend)) throw new Error('backend must be auto, sky or native');
544
547
  if (!['sky', 'native'].includes(capture)) throw new Error('capture must be sky or native');
548
+ if (!['none', 'auto', 'all'].includes(marks)) throw new Error('marks must be none, auto or all');
545
549
  if (backend === 'native' && !this.#native) throw new Error('Native UIA is not configured');
546
550
  const nativeOnly = this.#window.nativeOnly === true;
547
551
  if (nativeOnly) {
@@ -553,21 +557,33 @@ export class WindowsSession {
553
557
  if (!sameWindow(current, this.#window)) throw new Error('Window identity changed; bind it again');
554
558
  const useNative = nativeOnly || backend === 'native' || (backend === 'auto' && this.#nativePreferred);
555
559
  // Capture propre (PrintWindow, fenêtre recouverte, sans premier plan) ; image vide ou échec : capture Sky.
560
+ // Numéros [N] (shared/images.md point 5, avis de Codex du 24/10) : arbre natif lu AVANT la capture, une seule fois et
561
+ // sous le même verrou ; ses index et rectangles vont au helper, qui ne les dessine que si le cadre de la fenêtre est
562
+ // resté celui de la lecture. L'âge de l'observation part du début de cette lecture (FRESH_MS).
563
+ let marksNote = null, preread = null, treeStartedAt = null, candidates = null;
564
+ if (marks !== 'none') {
565
+ if (mode !== 'both') marksNote = 'numbers need mode both (tree and image together): none drawn';
566
+ else if (capture !== 'native' || !useNative || !this.#native?.inspect || !this.#native?.capture || !this.#root) marksNote = 'numbers need the native tree and capture: none drawn';
567
+ else { treeStartedAt = Date.now(); preread = await this.#native.inspect(current); }
568
+ }
556
569
  let images = [], imageFallback = null;
557
570
  if (capture === 'native' && mode !== 'text') {
558
571
  if (!this.#native?.capture || !this.#root) imageFallback = 'Native capture is not configured';
559
572
  else {
560
573
  const directory = path.join(this.#root, 'captures');
561
574
  try {
562
- const shot = await this.#native.capture(current, directory);
575
+ const shot = await this.#native.capture(current, directory, zoom,
576
+ preread ? { marks: (candidates = markCandidates(preread.elements, marks)), treeFrame: preread.frame ?? null } : null);
563
577
  if (shot.blank) imageFallback = 'PrintWindow returned a uniform (blank) image';
564
578
  // id : repère de coordonnées natif (banc commun du 21/10, plan de Codex étape 5), distinct des captures Sky ;
565
- // un point de cette image vise l'écran à frame.left + x, frame.top + y (image à la taille affichée).
566
- else images = [{ id: `native-${++this.#nativeShots}`, path: shot.path, width: shot.width, height: shot.height, method: shot.method, scale: shot.scale, ms: shot.ms, frame: shot.frame }];
579
+ // un point de cette image vise l'écran à frame.left + x * frame.width / width (image réduite au plus à
580
+ // 1568 px et 1,15 Mpx, règle commune des produits Nexus : nativeScreen).
581
+ else images = [{ id: `native-${++this.#nativeShots}`, path: shot.path, width: shot.width, height: shot.height, method: shot.method, scale: shot.scale, ms: shot.ms, frame: shot.frame, windowFrame: shot.windowFrame ?? shot.frame, ...(zoom ? { zoom: true } : {}), ...(preread ? { marks: { ...(shot.marks ?? { reason: 'the helper drew no numbers' }), ...(candidates?.capped ? { capped: candidates.capped } : {}) } } : {}) }];
567
582
  } catch (error) { imageFallback = error.message; }
568
583
  await this.#pruneCaptures(directory);
569
584
  }
570
585
  }
586
+ if (preread && !images.length) marksNote = 'no native image: none drawn';
571
587
  const skyText = mode !== 'image' && !useNative;
572
588
  const skyImage = mode !== 'text' && !images.length && !nativeOnly;
573
589
  const state = !skyText && !skyImage
@@ -584,14 +600,14 @@ export class WindowsSession {
584
600
  const nativeRead = mode !== 'image' && this.#native?.inspect && (useNative || (!elements.length && backend === 'auto'));
585
601
  let revision = nativeRead ? null : await this.#getRevision(state.window.app); // une lecture native donne la sienne
586
602
  if (nativeRead) {
587
- const native = await this.#native.inspect(state.window);
603
+ const native = preread ?? await this.#native.inspect(state.window);
588
604
  accessibility = native.accessibility; elements = native.elements; source = 'native-uia';
589
605
  revision = native.revision;
590
606
  this.#nativePreferred = true;
591
607
  }
592
608
  else if (mode !== 'image') this.#nativePreferred = false;
593
609
  const observation = freeze({
594
- token: randomUUID(), epoch: this.#coordinator.epoch, capturedAt: Date.now(), mode, source, revision,
610
+ token: randomUUID(), epoch: this.#coordinator.epoch, capturedAt: treeStartedAt ?? Date.now(), mode, source, revision,
595
611
  window: clone(state.window), windowStack: this.#stack.map(w => w.id), windowChange: clone(windowChange), popups: clone(this.#popups),
596
612
  elements, structureHash: structureHash(elements),
597
613
  accessibility: clone(accessibility),
@@ -599,15 +615,23 @@ export class WindowsSession {
599
615
  screenshots: (state.screenshots ?? []).map(({ id, width, height, originX, originY, zIndex }) =>
600
616
  ({ id, width, height, originX, originY, zIndex })),
601
617
  // Captures natives : fichiers locaux (LocalAppData) à regarder avec view_image ; jamais des cibles de coordonnées.
602
- images, imageFallback,
618
+ images, imageFallback, ...(marksNote ? { marksNote } : {}),
603
619
  });
604
620
  this.#observation = observation;
605
621
  return observation;
606
622
  }
607
- async observe({ mode = 'text', backend = 'auto', capture = 'sky' } = {}) {
623
+ // zoom : zone écran [left, top, width, height] d'une capture native (lookZoom), agrandie jusqu'à ×2.
624
+ // marks : none (défaut), auto (contrôles sans nom), all ; mode both et lecture native seulement (sinon marksNote).
625
+ // zoomWindow : fenêtre de l'image d'où vient la zone ; un dialogue suivi entre-temps la rend caduque (refus, rien lu
626
+ // dans un autre repère ; relecture de Codex du 24/10).
627
+ async observe({ mode = 'text', backend = 'auto', capture = 'sky', zoom = null, marks = 'none', zoomWindow = null } = {}) {
608
628
  return this.#exclusive(async () => {
629
+ this.#invalidate(); // un refus ci-dessous ne laisse aucune observation utilisable (relecture de Codex du 24/10)
609
630
  const windowChange = await this.#follow(false);
610
- return this.#capture(mode, backend, { capture, windowChange });
631
+ if (zoom && zoomWindow != null && this.#window?.id !== zoomWindow) {
632
+ throw new Error(`region comes from an image of window ${zoomWindow}, but the observed window is now ${this.#window?.id}: look again without region`);
633
+ }
634
+ return this.#capture(mode, backend, { capture, windowChange, zoom, marks });
611
635
  });
612
636
  }
613
637
  #take(observation) {
@@ -745,8 +769,9 @@ export class WindowsSession {
745
769
  if (leased) return null;
746
770
  // Point d'une capture native : clic (bouton, double) ou molette postés au contrôle sous le point ; le helper relit
747
771
  // le cadre de la fenêtre et refuse s'il a bougé depuis la capture.
748
- const f = screenshot.frame;
749
- const args = { mode: 'background', x: f.left + spec.point.x, y: f.top + spec.point.y, frame: [f.left, f.top, f.width, f.height] };
772
+ const f = screenshot.windowFrame ?? screenshot.frame;
773
+ const at = gardeFous.nativeScreen(screenshot, spec.point);
774
+ const args = { mode: 'background', x: at.x, y: at.y, frame: [f.left, f.top, f.width, f.height] };
750
775
  if (spec.kind === 'click') Object.assign(args, { action: 'click', button: spec.button ?? 'left', count: spec.count ?? 1 });
751
776
  else {
752
777
  const notches = v => (v ? Math.sign(v) * Math.max(1, Math.round(Math.abs(v) / 120)) : 0);
@@ -993,8 +1018,8 @@ export class WindowsSession {
993
1018
  // cible d'observation native ; null : pas de repère natif (Sky en secours).
994
1019
  #nativePoint(spec, basis, selected, screenshot, key = 'point') {
995
1020
  if (screenshot?.native && spec[key]) {
996
- const f = screenshot.frame;
997
- return { x: Math.round(f.left + spec[key].x), y: Math.round(f.top + spec[key].y), frame: [f.left, f.top, f.width, f.height] };
1021
+ const f = screenshot.windowFrame ?? screenshot.frame;
1022
+ return { ...gardeFous.nativeScreen(screenshot, spec[key]), frame: [f.left, f.top, f.width, f.height] };
998
1023
  }
999
1024
  if (basis.source === 'native-uia' && selected?.bounds?.length === 4 && selected.bounds[2] > 0 && selected.bounds[3] > 0) {
1000
1025
  const [x, y, w, h] = selected.bounds;
@@ -1486,5 +1511,6 @@ export function summarize(observation) {
1486
1511
  windowStack: observation.windowStack, windowChange: observation.windowChange, popups: observation.popups,
1487
1512
  tree: observation.accessibility?.tree ?? '', focused: observation.accessibility?.focused_element ?? '',
1488
1513
  screenshots: observation.screenshots, images: observation.images, imageFallback: observation.imageFallback,
1514
+ ...(observation.marksNote ? { marksNote: observation.marksNote } : {}),
1489
1515
  };
1490
1516
  }
package/lib/tree.mjs CHANGED
@@ -20,6 +20,31 @@ const stateFlags = new Set(['disabled', 'selected', 'unselected', 'selectable',
20
20
  'settable', 'string', 'float', 'integer', 'boolean']);
21
21
  export const digest = value => createHash('sha256').update(value).digest('hex');
22
22
 
23
+ // Numéros [N] sur l'image (shared/images.md point 5) : éléments natifs avec un rectangle, hors racine et hors éléments
24
+ // dits hors écran. auto : contrôles sans nom (espaces compris) qu'on peut viser ; all : tous. scope : HWND porteur le plus
25
+ // proche (l'élément ou un ancêtre), que le helper compare à la fenêtre capturée (menu surgissant d'une autre fenêtre :
26
+ // jamais d'étiquette fantôme). Index EXACT de la ligne de l'arbre, jamais renuméroté.
27
+ const MARKS_LIMIT = 400;
28
+ const MARKABLE = new Set(['button', 'splitbutton', 'edit', 'checkbox', 'radio', 'combobox', 'listitem', 'menuitem', 'tab',
29
+ 'treeitem', 'link', 'slider', 'spinner', 'dataitem', 'headeritem', 'image']);
30
+ export function markCandidates(elements = [], marks = 'auto') {
31
+ const byIndex = new Map(elements.map(e => [e.index, e]));
32
+ const out = [];
33
+ let capped = 0; // éléments admissibles au-delà du plafond : dits dans le rapport (all n'est alors pas exhaustif)
34
+ for (const e of elements) {
35
+ const b = e.bounds;
36
+ if (!Array.isArray(b) || b.length !== 4 || !b.every(Number.isFinite) || !(b[2] > 0) || !(b[3] > 0)) continue;
37
+ if (!e.ancestors?.length || e.flags?.includes('offscreen')) continue;
38
+ if (marks === 'auto' && (String(e.name ?? '').trim() || !(MARKABLE.has(e.role) || e.invoke === true))) continue;
39
+ const chain = [e, ...[...e.ancestors].reverse().map(i => byIndex.get(i))];
40
+ const scope = chain.find(x => x?.nativeWindow)?.nativeWindow ?? 0;
41
+ if (out.length >= MARKS_LIMIT) { capped++; continue; }
42
+ out.push({ index: e.index, bounds: b.map(Math.round), scope });
43
+ }
44
+ if (capped) Object.defineProperty(out, 'capped', { value: capped }); // hors JSON : seul le compte part au rapport
45
+ return out;
46
+ }
47
+
23
48
  export function parseTree(tree = '') {
24
49
  const elements = [], stack = [];
25
50
  for (const line of tree.split(/\r?\n/)) {
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/native/bin/NexusCodex.UIA.exe
3
- size: 35227685 bytes
4
- sha256: 1bbe1a15169b998562a5755774cae122e3720ec73f46cba4ede97a30a70920c9
3
+ size: 35244346 bytes
4
+ sha256: e348309a77c415cd4e4102bb6f20045b413589c79a48d815560ba99d67273a68
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-windows-codex",
3
- "version": "3.0.1",
3
+ "version": "3.1.1",
4
4
  "description": "NexusWindows for Codex: Windows computer use in the background for OpenAI Codex (MCP server and Codex skill).",
5
5
  "type": "module",
6
6
  "license": "MIT",