nexus-windows-codex 0.0.0-stage → 3.0.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.
@@ -0,0 +1,1490 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, writeFile, rename, stat, unlink, readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { ApplicationMemory } from './memory.mjs';
5
+ import { parseTree, uniqueElement, findElements, structureHash, stateDigest, checkExpectation, validateExpectation, digest } from './tree.mjs';
6
+ import { emergencyStop } from './stop.mjs';
7
+ import { JulienActiveError, PresenceUnknownError, QUIET_MS, MAX_WAIT_MS } from './presence.mjs';
8
+ import * as gardeFous from './garde-fous.mjs';
9
+ import { journalLine, writeJournal } from './journal.mjs';
10
+
11
+ const coordinators = new WeakMap();
12
+ // Même fenêtre : même HWND, même application et, quand les deux le connaissent (fournisseur natif), même processus : un
13
+ // HWND recyclé par un autre processus n'est jamais pris pour l'ancien (relecture de Codex du 23/10).
14
+ const sameWindow = (a, b) => a.id === b.id && a.app === b.app && (a.pid === undefined || b.pid === undefined || a.pid === b.pid);
15
+ const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
16
+ const clone = value => structuredClone(value);
17
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
18
+ function freeze(value) {
19
+ if (value && typeof value === 'object') {
20
+ for (const v of Object.values(value)) freeze(v);
21
+ Object.freeze(value);
22
+ }
23
+ return value;
24
+ }
25
+ function coordinator(sky) {
26
+ if (!coordinators.has(sky)) coordinators.set(sky, { epoch: 0, busy: false });
27
+ return coordinators.get(sky);
28
+ }
29
+ function targetIdentity(element, elements) {
30
+ const key = e => [e.role, e.automationId, e.name, e.runtimeId ?? null];
31
+ return [key(element), element.ancestors.map(i => key(elements.find(e => e.index === i)))];
32
+ }
33
+ // Erreur levée avant toute entrée : jamais un résultat inconnu, jamais rejouée.
34
+ const beforeInput = message => Object.assign(new Error(message), { beforeInput: true });
35
+
36
+ // ---- Postconditions : une condition déjà vraie ne vaut que si elle décrit l'effet de l'action ----
37
+ // Mesure du banc (16/10) : « le bouton existe » ou « l'option Bravo existe » (toujours vraies) faisaient passer un clic
38
+ // ou un Expand pour « already-satisfied » sans rien envoyer. Règle :
39
+ // 1. Refus d'une postcondition qui ne fait que redire la cible (existence de la cible, ou son nom déjà donné par le
40
+ // sélecteur) : vraie avant l'action, elle ne prouve rien.
41
+ // 2. Déjà vraie AVANT l'action : « already-satisfied » (rien n'est envoyé) seulement pour un état précis (`equals`) que
42
+ // l'action change — sur la cible, l'un des champs d'état (value, checked, selected, expanded, focused) ; ou sur un
43
+ // autre élément. Sinon (`exists`, `includes`, libellé de la cible) refus avant toute entrée : la condition ne
44
+ // distinguerait pas l'effet de l'état d'avant.
45
+ // 3. Choix dans une liste déroulante : prouvé par la valeur de la liste déroulante, jamais par l'élément d'option (exposé
46
+ // seulement liste ouverte, ou toujours présent selon le fournisseur).
47
+ const STATE_FIELDS = new Set(['value', 'checked', 'selected', 'expanded', 'focused']);
48
+ const label = text => String(text ?? '').split(/\s+/).filter(Boolean).join(' ');
49
+ // `window` (menu attesté, ancêtre seulement) fait partie de la clé : deux menus distincts ne sont pas la même cible
50
+ // (relecture de Codex du 22/10 08:07).
51
+ const selectorKey = s => s && JSON.stringify(['role', 'name', 'automationId', 'window'].map(k => s[k] ?? null).concat([s.ancestor ? selectorKey(s.ancestor) : null]));
52
+ const actionTarget = spec => spec.kind === 'typeText' ? spec.focus : spec.selector;
53
+ const restatesTarget = (expect, target) => (expect.exists === true && expect.equals === undefined && expect.includes === undefined) ? 'its existence'
54
+ : (expect.field ?? 'name') === 'name' && typeof target?.name === 'string' && (expect.equals === target.name || (expect.includes !== undefined && target.name.includes(expect.includes))) ? 'its own label'
55
+ : null;
56
+ const TRIVIAL = what => `Postcondition only restates the target (${what}): it is already true before the action and proves nothing. ` +
57
+ 'Describe the effect: a state the action changes (field value, checked, selected, expanded or focused with equals) or another element (for example a result label)';
58
+ // Identité native transmise au helper : identité d'exécution de la cible, chaîne de ses ancêtres telle qu'observée (le
59
+ // helper ne relit que la cible et ses ancêtres, et refuse tout écart) et fenêtre propre éventuelle (accès direct).
60
+ function nativeIdentity(target, observation) {
61
+ const ancestry = target.ancestors.map(i => observation.elements.find(e => e.index === i))
62
+ .map(e => ({ role: e.role, automationId: e.automationId, name: e.name, runtimeId: e.runtimeId ?? [], ...(e.nativeWindow ? { nativeWindow: e.nativeWindow } : {}) }));
63
+ return { runtimeId: target.runtimeId, ancestry, ...(target.nativeWindow ? { nativeWindow: target.nativeWindow } : {}) };
64
+ }
65
+ // Élément signalé hors champ (UIA IsOffscreen, drapeau « offscreen ») dont le rectangle ne recoupe pas celui de sa
66
+ // fenêtre (racine de l'arbre) : rien n'en est visible. Rectangles UIA : [x, y, largeur, hauteur].
67
+ function hiddenOffscreen(element, elements) {
68
+ if (!element.flags?.includes('offscreen')) return false;
69
+ const [x, y, w, h] = element.bounds ?? [];
70
+ const [rx, ry, rw, rh] = elements[0]?.bounds ?? [];
71
+ if (!(w > 0 && h > 0 && rw > 0 && rh > 0)) return true;
72
+ return x >= rx + rw || y >= ry + rh || x + w <= rx || y + h <= ry;
73
+ }
74
+ function trivialPostcondition(spec) {
75
+ const target = actionTarget(spec);
76
+ if (!spec.expect || !target || selectorKey(spec.expect.selector) !== selectorKey(target)) return null;
77
+ return restatesTarget(spec.expect, target);
78
+ }
79
+ // Découpe en graphèmes (jamais au milieu d'un emoji ou d'un caractère combiné), par tranches bornées.
80
+ export function textChunks(text, size = 48) {
81
+ const graphemes = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)].map(s => s.segment);
82
+ const chunks = [];
83
+ for (let i = 0; i < graphemes.length; i += size) chunks.push(graphemes.slice(i, i + size).join(''));
84
+ return chunks.length ? chunks : [''];
85
+ }
86
+
87
+ const CAPTURE_RETENTION_MS = 10 * 60 * 1000, CAPTURE_KEEP = 32, BATCH_MAX = 32;
88
+ const FRESH_MS = 162; // ≈ 1,618 × 100 ms : au-delà, l'état est relu avant l'entrée
89
+ const TYPE_CHUNK = 1; // frappe physique par graphème : l'arrêt d'urgence est contrôlé avant chacun
90
+ const PRESENCE_POLL_MS = 50, PRESENCE_POLLS = 33; // ≈ 1,618 s d'attente du retour du garde avant refus
91
+ // 'key' : touches et raccourcis Ctrl/Maj postés en Win32/WinForms (banc commun du 21/10) ; ailleurs, non applicable → Sky.
92
+ const BACKGROUND_KINDS = new Set(['click', 'setValue', 'typeText', 'secondary', 'select', 'menu', 'key']);
93
+ // Champ destinataire (À, Cc, Cci, To, Destinataire, numéro) : montré à part dans le résumé de l'accord.
94
+ const RECIPIENT_FIELD = /^((a|to|cc|cci|bcc)( recipients?| destinataires?)?|destinataires?|recipients?|e ?mail|courriel|adresse (e ?mail|electronique)|email address|tel|telephone|numero de telephone|phone( number)?|beneficiaire|iban)( |$)/;
95
+ const plainName = name => String(name).normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^0-9a-z]+/g, ' ').trim();
96
+ const actionFields = {
97
+ click: ['selector', 'point', 'screenshotId', 'button', 'count'],
98
+ setValue: ['selector', 'value'], typeText: ['focus', 'text'], key: ['key'],
99
+ secondary: ['selector', 'action'], scroll: ['point', 'screenshotId', 'scrollX', 'scrollY'],
100
+ drag: ['from', 'to', 'screenshotId'],
101
+ select: ['selector', 'option'], menu: ['path'],
102
+ };
103
+ // Capture native de l'observation désignée par son id : repère de coordonnées pour les gestes d'arrière-plan et pour la
104
+ // confirmation (screenPoints : origine écran du cadre).
105
+ function nativeShot(observation, id) {
106
+ const image = (observation.images ?? []).find(i => i.id === id);
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 };
109
+ }
110
+ function validateAction(spec) {
111
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec) || !Object.hasOwn(actionFields, spec.kind)) throw new Error('Unsupported action');
112
+ const allowed = ['kind', 'expect', 'timeoutMs', 'refresh', 'requiredStructureHash', 'requiredRevision', 'fallback', 'confirm', 'accord', ...actionFields[spec.kind]];
113
+ if (Object.keys(spec).some(k => !allowed.includes(k))) throw new Error('Unexpected action field for ' + spec.kind);
114
+ if (spec.expect !== undefined) validateExpectation(spec.expect);
115
+ const trivial = trivialPostcondition(spec);
116
+ if (trivial) throw beforeInput(TRIVIAL(trivial));
117
+ if (spec.timeoutMs !== undefined && (!Number.isFinite(spec.timeoutMs) || spec.timeoutMs < 0 || spec.timeoutMs > 15000)) throw new Error('timeoutMs must be between 0 and 15000');
118
+ if (spec.refresh !== undefined && !['text', 'image', 'both'].includes(spec.refresh)) throw new Error('Invalid refresh mode');
119
+ if (spec.fallback !== undefined && typeof spec.fallback !== 'boolean') throw new Error('fallback must be boolean');
120
+ if (spec.confirm !== undefined && typeof spec.confirm !== 'boolean') throw new Error('confirm must be boolean');
121
+ if (spec.accord !== undefined && (typeof spec.accord !== 'string' || !/^[0-9a-f]{8}$/i.test(spec.accord.trim()))) throw new Error('accord must be the 8-character token returned by the refusal');
122
+ for (const key of ['point', 'from', 'to']) {
123
+ const p = spec[key];
124
+ if (p !== undefined && (!p || typeof p !== 'object' || Array.isArray(p) ||
125
+ Object.keys(p).some(k => k !== 'x' && k !== 'y') || !Number.isFinite(p.x) || !Number.isFinite(p.y))) {
126
+ throw new Error('A coordinate point must contain only finite x and y');
127
+ }
128
+ }
129
+ if (spec.kind === 'click') {
130
+ if (spec.button !== undefined && !['left', 'right', 'middle'].includes(spec.button)) throw new Error('Invalid mouse button');
131
+ if (spec.count !== undefined && ![1, 2].includes(spec.count)) throw new Error('click count must be 1 or 2');
132
+ if (spec.screenshotId !== undefined && spec.point === undefined) throw new Error('A screenshotId requires a coordinate point');
133
+ }
134
+ if (spec.kind === 'setValue' && typeof spec.value !== 'string') throw new Error('value must be a string');
135
+ if (spec.kind === 'typeText' && typeof spec.text !== 'string') throw new Error('text must be a string');
136
+ if (spec.kind === 'select' && (typeof spec.option !== 'string' || !spec.option.trim())) throw new Error('option must be a nonempty string');
137
+ if (spec.kind === 'menu' && (typeof spec.path !== 'string' || spec.path.split('>').some(s => !s.trim()))) throw new Error('menu path must look like "File > Save"');
138
+ if (spec.kind === 'key') {
139
+ if (typeof spec.key !== 'string' || !spec.key.trim().length) throw new Error('key must be a nonempty chord');
140
+ if (spec.key.split('+').some(k => /^(?:win|windows|meta|super|cmd|command|os)(?:_[lr])?$/i.test(k.trim()))) {
141
+ throw new Error('Windows-key chords are unsupported by the installed Computer Use skill');
142
+ }
143
+ }
144
+ if (spec.kind === 'scroll' && ![spec.scrollX ?? 0, spec.scrollY ?? 0].every(Number.isFinite)) throw new Error('Invalid scroll delta');
145
+ }
146
+
147
+ export class WindowsSession {
148
+ #sky; #coordinator; #window; #observation; #busy = false; #results = new WeakMap();
149
+ #memory; #root; #metrics = []; #sessionId = randomUUID(); #native; #nativePreferred = false; #stop;
150
+ #stack = []; #seen = new Set(); #pause; #popups = []; #interaction = null; #actLease; #batchLease; #tokenHeld = null; #nativeShots = 0; #openedSettings = null;
151
+ constructor(sky, { stateRoot, maxAgeMs = 30000, native, emergencyStop: stop = emergencyStop(), pause = {} } = {}) {
152
+ if (sky?.target !== 'windows') throw new Error('The supported Windows Sky API is required');
153
+ if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0 || maxAgeMs > 60000) throw new Error('maxAgeMs must be between 1 and 60000');
154
+ this.#sky = sky; this.#coordinator = coordinator(sky); this.maxAgeMs = maxAgeMs;
155
+ this.#root = stateRoot; this.#memory = stateRoot ? new ApplicationMemory(stateRoot) : null; this.#native = native;
156
+ // Après une action d'arrière-plan, l'arbre renvoyé par Sky peut encore décrire l'ancien état.
157
+ // Lire UIA dès le départ quand il est disponible ; backend:'sky' reste un choix explicite et persistant.
158
+ this.#nativePreferred = Boolean(native?.inspect);
159
+ this.#stop = stop;
160
+ this.#pause = { quietMs: pause.quietMs ?? QUIET_MS, maxWaitMs: pause.maxWaitMs ?? MAX_WAIT_MS, ...(pause.sleep ? { sleep: pause.sleep } : {}) };
161
+ }
162
+ // Arrêt d'urgence de Julien : point de contrôle avant toute action de bureau. Un refus consomme l'observation
163
+ // (rien n'est rejoué ensuite) ; les observations restent permises.
164
+ #assertNotStopped() {
165
+ try { this.#stop.assertClear(); }
166
+ catch (error) { this.#invalidate(); throw error; }
167
+ }
168
+ emergencyStopStatus() { return this.#stop.status(); }
169
+ presenceStatus() { return this.#stop.presence?.status() ?? { source: 'none', available: false, idleMs: null }; }
170
+ async #exclusive(fn) {
171
+ if (this.#busy || this.#coordinator.busy) throw new Error('Another Windows operation is running; serialize desktop interaction');
172
+ this.#busy = this.#coordinator.busy = true;
173
+ try { return await fn(); } finally { this.#busy = this.#coordinator.busy = false; this.#actLease = undefined; }
174
+ }
175
+ #invalidate() { this.#observation = null; this.#coordinator.epoch++; }
176
+ async windows() { return this.#exclusive(() => this.#sky.list_windows()); }
177
+ // Ouvre une page des Paramètres (« ms-settings:display ») par le helper, sans Sky (son lancement refuse les adresses,
178
+ // passe 2 du 22/10). Rend la fenêtre Sky à lier (`bind`), relue dans l'inventaire de Sky ; jamais liée d'office.
179
+ async openSettings(uri) {
180
+ return this.#working(() => this.#exclusive(async () => {
181
+ this.#assertNotStopped();
182
+ this.#openedSettings = null; // une ouverture précédente n'est jamais liable après une nouvelle demande
183
+ if (this.#interaction) throw beforeInput('End the foreground interaction before opening Settings');
184
+ if (!/^ms-settings:[A-Za-z0-9-]*$/.test(String(uri))) throw Object.assign(new Error('Only ms-settings: pages can be opened'), { refused: true, beforeInput: true });
185
+ if (!this.#native?.openSettings) throw new Error('openSettings needs the native helper');
186
+ gardeFous.load(); // politique commune illisible : refus avant tout lancement (le helper la relit aussi)
187
+ this.#invalidate(); // la page peut changer la fenêtre liée : toute observation antérieure est périmée
188
+ // Prise brève du premier plan : même règle qu'une entrée physique (pause de Julien) ; le helper prend lui-même le
189
+ // jeton commun et rend la fenêtre de Julien.
190
+ const reply = (await this.#physicalInput('open Settings page', presence =>
191
+ this.#native.openSettings(uri, presence?.available ? { quietMs: this.#pause.quietMs } : {}), { token: false })).value;
192
+ if (!reply?.window) return { status: 'unknown', reason: 'No Settings window found within 5 s; list windows before deciding' };
193
+ const listed = (await this.#sky.list_windows()).find(w => w.id === reply.window) ?? null;
194
+ // Ouverte masquée (lancement d'arrière-plan, mesure du 23/10) : Sky ne la liste pas ; seule CETTE fenêtre peut
195
+ // être liée par bindOpenedSettings(), lue et pilotée par le helper seul. Jamais une fenêtre masquée quelconque.
196
+ this.#openedSettings = listed ? null : { id: reply.window, app: reply.app, pid: reply.pid, title: reply.title ?? '' };
197
+ return { status: 'opened', window: listed, hidden: reply.hidden === true, bindable: listed ? 'bind' : 'bindOpenedSettings',
198
+ nativeWindow: reply.window, title: reply.title ?? null, foregroundReturned: reply.foregroundReturned ?? null };
199
+ }));
200
+ }
201
+ // Fenêtre liée de l'Explorateur menée à un dossier (chemin absolu existant) par le shell, en arrière-plan : ni Ctrl+L
202
+ // ni barre d'adresse (passe 2 du 22/10 : sans focus en arrière-plan, saisie perdue au premier plan rendu). Fenêtre à
203
+ // un seul onglet seulement. Arrivée relue ; observer ensuite.
204
+ async openFolder(path) {
205
+ return this.#working(() => this.#exclusive(async () => {
206
+ this.#assertNotStopped();
207
+ if (!this.#window) throw new Error('Bind an Explorer window first');
208
+ if (!this.#native?.call) throw new Error('openFolder needs the native helper');
209
+ this.#invalidate(); // le contenu de la fenêtre va changer : observations antérieures périmées
210
+ const reply = await this.#native.call(this.#window, 'navigateFolder', { path: String(path) });
211
+ return { status: reply.effect === 'verified' || reply.effect === 'already' ? 'verified' : 'unknown', effect: reply.effect, path: reply.path ?? null };
212
+ }));
213
+ }
214
+ // Lancement par le helper, sans Sky (serveur MCP) : application (nom, chemin, AUMID), dossier ou document, sous le
215
+ // jeton commun, premier plan rendu à chaque prise ; une page ms-settings: passe par openSettings. Rend les nouvelles
216
+ // fenêtres d'application, jamais liées d'office.
217
+ async launch(target) {
218
+ if (/^ms-settings:/i.test(String(target).trim())) return this.openSettings(String(target).trim());
219
+ return this.#working(() => this.#exclusive(async () => {
220
+ this.#assertNotStopped();
221
+ if (this.#interaction) throw beforeInput('End the foreground interaction before launching');
222
+ if (!this.#native?.launch) throw new Error('launch needs the native helper');
223
+ gardeFous.load(); // politique commune illisible : refus avant tout lancement (le helper la relit aussi)
224
+ this.#invalidate(); // le lancement peut changer la fenêtre liée (document ouvert dedans) : observation périmée
225
+ const start = Date.now();
226
+ const reply = (await this.#physicalInput('launch an application', presence =>
227
+ this.#native.launch(String(target), presence?.available ? { quietMs: this.#pause.quietMs } : {}), { token: false })).value;
228
+ const opened = reply?.opened === true;
229
+ this.#record('launch', opened ? 'verified' : 'unknown', Date.now() - start, 'shell', true);
230
+ const candidates = reply?.candidates ?? [];
231
+ return { status: opened ? 'opened' : 'unknown', windows: reply?.windows ?? [], candidates, foregroundReturned: reply?.foregroundReturned ?? null,
232
+ ...(opened ? {} : { reason: candidates.length
233
+ ? 'New windows appeared but none is attested as this launch (process, executable or document name); look before deciding, never launch again blindly'
234
+ : 'No new application window within 15 s; list windows before deciding, never launch again blindly' }) };
235
+ }));
236
+ }
237
+ // Fermeture de la fenêtre liée : WM_CLOSE posté une fois par le helper (jamais rejoué), relu 3 s. Encore ouverte avec
238
+ // une fenêtre possédée (enregistrer ?) : à regarder avant de décider.
239
+ async closeWindow() {
240
+ return this.#working(() => this.#exclusive(async () => {
241
+ this.#assertNotStopped();
242
+ if (!this.#window) throw new Error('Bind a window first');
243
+ if (this.#interaction) throw beforeInput('End the foreground interaction before closing');
244
+ const start = Date.now();
245
+ this.#invalidate();
246
+ const reply = await this.#native.call(this.#window, 'close');
247
+ const status = reply.state === 'closed' ? 'verified' : reply.state === 'hidden' ? 'sent-unconfirmed' : (reply.prompts ?? []).length ? 'prompted' : 'unmet';
248
+ this.#record('close', status, Date.now() - start, 'background:messages', false);
249
+ if (reply.state === 'closed') { this.#window = null; this.#stack = []; }
250
+ return { status, state: reply.state, prompts: reply.prompts ?? [] };
251
+ }));
252
+ }
253
+ // Retour à la fenêtre liée elle-même (racine de la pile), sans nouvel inventaire : les dialogues suivis depuis sont
254
+ // retirés de la pile. Pour une racine que l'inventaire ne liste pas (Paramètres ouverts masqués), son identité est
255
+ // relue par le helper.
256
+ async returnToRoot(id) {
257
+ return this.#exclusive(async () => {
258
+ if (this.#interaction) throw beforeInput('End the foreground interaction before binding another window');
259
+ const root = this.#stack[0];
260
+ if (!root || root.id !== id) throw new Error('This window is no longer bound; list windows');
261
+ if (root.nativeOnly && (await this.#native.alive(root)).state !== 'alive') throw new Error('Window identity changed; bind it again');
262
+ this.#invalidate(); this.#stack = [root]; this.#window = root;
263
+ await this.#markSeen();
264
+ return clone(root);
265
+ });
266
+ }
267
+ async bindOpenedSettings() {
268
+ return this.#exclusive(async () => {
269
+ if (this.#interaction) throw beforeInput('End the foreground interaction before binding another window');
270
+ const opened = this.#openedSettings;
271
+ if (!opened?.id || !opened.app || !Number.isInteger(opened.pid)) throw new Error('No hidden Settings window opened by openSettings in this session');
272
+ const win = { id: opened.id, app: opened.app, title: opened.title, nativeOnly: true, pid: opened.pid };
273
+ if ((await this.#native.alive(win)).state !== 'alive') throw new Error('The opened Settings window is gone; open it again');
274
+ this.#invalidate(); this.#seen.clear(); this.#popups = []; this.#nativePreferred = true;
275
+ this.#window = win; this.#stack = [win]; this.#openedSettings = null;
276
+ await this.#markSeen();
277
+ return clone(win);
278
+ });
279
+ }
280
+ // Toute prise du premier plan (entrée physique Sky, activation, set_value et action secondaire de Sky) passe ici, sous
281
+ // le jeton commun. Le helper note la fenêtre active de Julien dès le jeton pris et la lui rend sous le jeton si la
282
+ // fenêtre cible tient encore le premier plan à la fin ; `report.foregroundReturned` reçoit l'état rapporté.
283
+ async #foreground(purpose, action, report = {}, keep = undefined) {
284
+ if (!this.#native?.withForeground) throw Object.assign(new Error('Foreground token helper is required; action deferred'), { deferred: true });
285
+ this.#assertNotStopped();
286
+ const interaction = this.#interaction;
287
+ if (interaction?.active) {
288
+ if (interaction.reason) throw beforeInput('Foreground interaction ended: ' + interaction.reason);
289
+ if (this.#window?.app !== interaction.window.app) throw beforeInput('Foreground interaction belongs to another application');
290
+ interaction.running++;
291
+ this.#tokenHeld = interaction.held ?? null;
292
+ try {
293
+ const value = await action();
294
+ // Helper du bail perdu pendant l'action : une entrée a pu partir sans jeton ; inconnu dès cette action, pas
295
+ // seulement à la fin de l'interaction (relecture de Codex, 21/10).
296
+ if (interaction.held?.() === false) {
297
+ interaction.finish?.('token-lost');
298
+ throw new Error('Foreground token lost during the action; outcome unknown, do not replay');
299
+ }
300
+ return value;
301
+ } finally {
302
+ this.#tokenHeld = null;
303
+ interaction.running--;
304
+ if (interaction.reason && !interaction.running) interaction.release();
305
+ report.foregroundReturned = 'interaction-held';
306
+ }
307
+ }
308
+ // Le jeton peut attendre jusqu'à 8 s : contrôle de nouveau une fois acquis, juste avant l'entrée.
309
+ try {
310
+ return await this.#native.withForeground(purpose, lease => { this.#assertNotStopped(); this.#tokenHeld = lease?.held ?? null; return action(); },
311
+ { window: this.#window, keep, onRelease: state => { report.foregroundReturned = state ?? null; } });
312
+ } finally { this.#tokenHeld = null; }
313
+ }
314
+ // Menus transitoires et canvas : conserver le même bail pendant une courte
315
+ // séquence observe -> agir -> observer. Jamais de bail sans limite, ni de
316
+ // verrou réentrant partagé avec un autre objet/session.
317
+ async beginInteraction({ durationMs = 30000 } = {}) {
318
+ return this.#exclusive(async () => {
319
+ if (!this.#window) throw beforeInput('Bind a window first');
320
+ if (this.#interaction) throw beforeInput('End the current foreground interaction first');
321
+ if (!Number.isSafeInteger(durationMs) || durationMs < 100 || durationMs > 30000) throw beforeInput('durationMs must be between 100 and 30000');
322
+ if (!this.#native?.withForeground) throw beforeInput('Foreground token helper is required');
323
+ this.#assertNotStopped();
324
+ const presence = this.#stop.presence;
325
+ await this.#knownPresence(presence);
326
+ if (presence?.available) await presence.waitForPause(this.#pause);
327
+ const interaction = { window: clone(this.#window), active: false, running: 0, reason: null, giveBack: null, release: null, done: null };
328
+ let ready, failed;
329
+ const observation = new Promise((resolve, reject) => { ready = resolve; failed = reject; });
330
+ const released = new Promise(resolve => { interaction.release = resolve; });
331
+ const finish = reason => {
332
+ interaction.reason ??= reason;
333
+ this.#invalidate();
334
+ if (!interaction.running) interaction.release();
335
+ };
336
+ interaction.finish = finish;
337
+ this.#interaction = interaction;
338
+ interaction.done = this.#native.withForeground('interactive observation and input', async lease => {
339
+ interaction.held = lease?.held ?? null;
340
+ this.#assertNotStopped();
341
+ if (presence?.available && presence.idleMs() < this.#pause.quietMs) throw new JulienActiveError(presence.idleMs());
342
+ const began = presence?.now?.() ?? 0;
343
+ interaction.began = began;
344
+ // Mise au premier plan par le helper lui-même (sans Sky) quand il est là ; Sky en secours.
345
+ let activated = false;
346
+ if (this.#nativeInput()) {
347
+ try { await this.#native.call(interaction.window, 'activate'); activated = true; } catch (error) { if (!error.notApplicable) throw error; }
348
+ }
349
+ if (!activated && interaction.window.nativeOnly) throw beforeInput('The helper cannot bring this window forward and Sky does not list it');
350
+ if (!activated) await this.#sky.activate_window({ window: interaction.window });
351
+ this.#assertNotStopped();
352
+ interaction.active = true;
353
+ const timer = setTimeout(() => finish('timeout'), durationMs);
354
+ const guard = setInterval(() => {
355
+ try {
356
+ this.#assertNotStopped();
357
+ if (presence?.available && presence.since(began)) finish('julien-active');
358
+ else if (this.#stop.guardExpected && !presence?.available) finish('presence-unknown');
359
+ } catch { finish('emergency-stop'); }
360
+ }, PRESENCE_POLL_MS);
361
+ try {
362
+ ready(await this.#capture('both'));
363
+ await released;
364
+ } finally { clearTimeout(timer); clearInterval(guard); interaction.active = false; }
365
+ }, { window: interaction.window,
366
+ // Julien a touché clavier ou souris depuis le début du bail (présence, injections exclues), quelle que soit la
367
+ // raison de clôture : son choix est gardé (relecture de Codex, 21/10 : fin avant le contrôle périodique).
368
+ keep: () => interaction.reason === 'julien-active' || Boolean(presence?.available && interaction.began != null && presence.since(interaction.began)),
369
+ onRelease: state => { interaction.giveBack = state ?? null; } })
370
+ .catch(error => { failed(error); interaction.error = error; })
371
+ .finally(() => { this.#invalidate(); if (this.#interaction === interaction) this.#interaction = null; });
372
+ return observation;
373
+ });
374
+ }
375
+ async endInteraction() {
376
+ return this.#exclusive(async () => {
377
+ const interaction = this.#interaction;
378
+ if (!interaction) return { status: 'inactive' };
379
+ interaction.reason ??= 'ended';interaction.release();
380
+ await interaction.done;
381
+ if (interaction.error) throw interaction.error;
382
+ return { status: interaction.reason, foregroundReturned: interaction.giveBack };
383
+ });
384
+ }
385
+ async close() {
386
+ await this.endInteraction();
387
+ await this.pruneCaptures();
388
+ return this.#exclusive(() => { this.#invalidate(); this.#window = null; this.#stack = []; this.#seen.clear(); this.#openedSettings = null; this.#native?.close?.(); });
389
+ }
390
+ // Captures natives : 32 au plus, 10 minutes au plus ; purgées à chaque capture, au démarrage du serveur et à son arrêt
391
+ // propre (relecture de Codex du 23/10 : sans cela, les dernières images restaient après l'arrêt).
392
+ async pruneCaptures() { if (this.#root) await this.#pruneCaptures(path.join(this.#root, 'captures')); }
393
+ // Logo « agent au travail » pendant une action (jamais pendant une simple observation) ; sur l'écran de la fenêtre liée.
394
+ async #working(fn) {
395
+ const stop = this.#stop;
396
+ if (typeof stop.busy !== 'function') return fn();
397
+ stop.busy(Number(this.#window?.id));
398
+ try { return await fn(); } finally { stop.idle(); }
399
+ }
400
+ async activate() {
401
+ return this.#working(() => this.#exclusive(async () => {
402
+ this.#invalidate();
403
+ if (!this.#window) throw new Error('Bind a window first');
404
+ this.#assertNotStopped();
405
+ // Activation = prise du premier plan : même règle qu'une entrée physique (pause de Julien, jeton, fenêtre rendue).
406
+ const result = await this.#physicalInput('activate_window', async () => {
407
+ if (this.#window.nativeOnly) await this.#native.call(this.#window, 'activate');
408
+ else await this.#sky.activate_window({ window: this.#window });
409
+ // Capturer avant de rendre le premier plan : sinon les pixels peuvent
410
+ // montrer l'application de Julien qui vient justement d'etre restauree.
411
+ return this.#capture('both');
412
+ });
413
+ return result.value;
414
+ }));
415
+ }
416
+ async bind({ id, app, title } = {}) {
417
+ return this.#exclusive(async () => {
418
+ if (this.#interaction) throw beforeInput('End the foreground interaction before binding another window');
419
+ this.#invalidate(); this.#window = null; this.#stack = []; this.#seen.clear(); this.#nativePreferred = Boolean(this.#native?.inspect); this.#popups = [];
420
+ if (id === undefined && app === undefined && title === undefined) throw new Error('Specify a returned window id, exact app or exact title');
421
+ const found = (await this.#sky.list_windows()).filter(w =>
422
+ (id === undefined || id === w.id) && (app === undefined || app === w.app) && (title === undefined || title === w.title));
423
+ if (found.length !== 1) throw new Error(`Window selection must be unique; found ${found.length}`);
424
+ const win = await this.#sky.get_window({ id: found[0].id, app: found[0].app });
425
+ if (!sameWindow(win, found[0])) throw new Error('Window identity changed during selection');
426
+ this.#window = win; this.#stack = [win];
427
+ await this.#markSeen(); // les fenêtres liées déjà ouvertes ne sont pas suivies d'office
428
+ return clone(win);
429
+ });
430
+ }
431
+
432
+ // ---- Fenêtres liées : dialogues et surgissantes possédés, ou du même processus apparus pendant l'action ----
433
+ async #related() {
434
+ if (!this.#native?.related || !this.#stack.length) return null;
435
+ try { return await this.#native.related(this.#stack[0]); } catch { return null; }
436
+ }
437
+ async #markSeen() {
438
+ const info = await this.#related();
439
+ for (const w of info?.windows ?? []) this.#seen.add(w.id);
440
+ }
441
+ async #follow(inAction) {
442
+ const info = await this.#related();
443
+ if (!info) return null;
444
+ const open = new Set(info.windows.map(w => w.id));
445
+ let change = null;
446
+ while (this.#stack.length > 1 && !open.has(this.#stack.at(-1).id)) {
447
+ // Absente de la liste VISIBLE : dépilée seulement si le helper atteste sa disparition (gone|other) ; masquée mais
448
+ // vivante, ou existence illisible : gardée (relecture de Codex). Sans lecture d'existence : comportement d'avant.
449
+ if (this.#native?.alive) {
450
+ let state = null;
451
+ try { state = (await this.#native.alive(this.#stack.at(-1))).state; } catch { /* inconnu : gardée */ }
452
+ if (state !== 'gone' && state !== 'other') break;
453
+ }
454
+ const closed = this.#stack.pop();
455
+ change = { reason: 'closed', from: closed.id, to: this.#stack.at(-1).id };
456
+ }
457
+ // Fenêtres attestées par le helper : même processus et même propriétaire qu'à leur adoption, relus à chaque suivi.
458
+ for (const [i, w] of this.#stack.entries()) {
459
+ if (!w.nativeOnly) continue;
460
+ const seen = info.windows.find(x => x.id === w.id);
461
+ // Propriétaire DIRECT attesté (GW_OWNER, aussi imposé au helper) et ancre suivie (fenêtre de la pile dans la
462
+ // chaîne), gardés séparément : un propriétaire intermédiaire non suivi ou une fenêtre sœur restent valides
463
+ // (relecture de Codex).
464
+ const anchored = this.#stack.slice(0, i).some(s => s.id === w.anchor);
465
+ // Sans propriétaire (fenêtre du même processus apparue pendant l'action, fournisseur natif) : le processus seul.
466
+ const ownerChanged = w.owner !== undefined && (seen?.ownerChain[0] !== w.owner || !seen?.ownerChain.includes(w.anchor) || !anchored);
467
+ if (seen && (seen.pid !== w.pid || ownerChanged)) {
468
+ throw new Error('Window identity changed (process or owner); bind it again');
469
+ }
470
+ }
471
+ const inStack = new Set(this.#stack.map(w => w.id));
472
+ const top = this.#stack.at(-1);
473
+ const topEnabled = top === this.#stack[0] ? info.boundEnabled !== false : info.windows.find(w => w.id === top.id)?.enabled !== false;
474
+ // Surgissantes transitoires (liste déroulante ouverte, infobulle, surgissante WPF : sans barre de titre et non
475
+ // activables) : parties de la fenêtre suivie, lues par son arbre ; jamais suivies comme une nouvelle fenêtre (le
476
+ // banc du 16/10 perdait ainsi tous les contrôles de la fenêtre WPF : « found 0 »). Signalées dans `obs.popups`.
477
+ this.#popups = info.windows.filter(w => w.popup).map(w => ({ id: w.id, className: w.className ?? null }));
478
+ const candidates = info.windows.filter(w => !inStack.has(w.id) && !w.popup && (
479
+ // Possédée par une fenêtre suivie : nouvelle, ou fenêtre active désactivée par ce dialogue (modal).
480
+ (w.ownerChain.some(o => inStack.has(o)) && (!this.#seen.has(w.id) || (!topEnabled && w.enabled))) ||
481
+ // Même processus, sans propriétaire, apparue pendant l'action.
482
+ (inAction && w.ownerChain.length === 0 && !this.#seen.has(w.id))))
483
+ .sort((a, b) => a.z - b.z);
484
+ for (const w of info.windows) this.#seen.add(w.id);
485
+ for (const next of candidates) {
486
+ const listed = (await this.#sky.list_windows()).find(s => s.id === next.id && s.app === this.#stack[0].app);
487
+ let win = null;
488
+ if (listed) {
489
+ win = await this.#sky.get_window({ id: listed.id, app: listed.app });
490
+ if (!sameWindow(win, listed)) continue;
491
+ // Fournisseur natif (serveur MCP sans Sky) : la fenêtre listée est pilotée par le helper seul ; elle garde
492
+ // son processus, son propriétaire et son ancre comme une fenêtre attestée, sinon le suivi suivant la rejette
493
+ // alors que son identité n'a pas changé (relecture de Codex du 23/10).
494
+ if (win.nativeOnly) {
495
+ if (next.pid !== win.pid) continue;
496
+ win = { ...win, pid: next.pid, owner: next.ownerChain[0], anchor: next.ownerChain.find(o => inStack.has(o)) };
497
+ }
498
+ } else {
499
+ // Absente de Sky (boîtes Ouvrir/Enregistrer sous du Bloc-notes WinUI et de Paint, passe 2 du 22/10) : suivie
500
+ // seulement si le helper l'atteste POSSÉDÉE par une fenêtre suivie ET du MÊME processus que la fenêtre liée ;
501
+ // lue, capturée et pilotée ensuite par le helper seul, jamais par Sky (diagnostic de Codex).
502
+ const owned = next.ownerChain.some(o => inStack.has(o));
503
+ if (!owned || !Number.isInteger(info.boundPid) || next.pid !== info.boundPid || !this.#native?.inspect || !this.#native?.alive) continue;
504
+ // pid et propriétaire gardés : relus à chaque suivi et imposés au helper à chaque requête (expectedPid/Owner).
505
+ const anchor = next.ownerChain.find(o => inStack.has(o));
506
+ win = { id: next.id, app: this.#stack[0].app, title: next.title ?? '', nativeOnly: true, pid: next.pid, owner: next.ownerChain[0], anchor };
507
+ }
508
+ this.#stack.push(win);
509
+ change = { reason: 'opened', from: top.id, to: win.id, owned: next.ownerChain.length > 0 };
510
+ break;
511
+ }
512
+ this.#window = this.#stack.at(-1);
513
+ return change;
514
+ }
515
+
516
+ async #getRevision(app) {
517
+ if (!app.startsWith('process:')) return null; // Store app ids do not expose a binary version.
518
+ try {
519
+ const info = await stat(app.slice('process:'.length));
520
+ return digest(`${info.size}:${info.mtimeMs}`);
521
+ } catch (e) {
522
+ if (['ENOENT', 'EACCES', 'EPERM'].includes(e.code)) return null;
523
+ throw e;
524
+ }
525
+ }
526
+ async #pruneCaptures(directory) {
527
+ let files;
528
+ try { files = (await readdir(directory)).filter(f => f.endsWith('.png')); } catch { return; }
529
+ const dated = [];
530
+ for (const name of files) {
531
+ try { dated.push({ file: path.join(directory, name), at: (await stat(path.join(directory, name))).mtimeMs }); } catch { /* déjà retiré */ }
532
+ }
533
+ dated.sort((a, b) => b.at - a.at);
534
+ const now = Date.now();
535
+ for (const [i, entry] of dated.entries()) {
536
+ if (i >= CAPTURE_KEEP || now - entry.at > CAPTURE_RETENTION_MS) await unlink(entry.file).catch(() => {});
537
+ }
538
+ }
539
+ async #capture(mode, backend = 'auto', { capture = 'sky', windowChange = null } = {}) {
540
+ this.#invalidate();
541
+ if (!this.#window) throw new Error('Bind a returned window before observing');
542
+ if (!['text', 'image', 'both'].includes(mode)) throw new Error('mode must be text, image or both');
543
+ if (!['auto', 'sky', 'native'].includes(backend)) throw new Error('backend must be auto, sky or native');
544
+ if (!['sky', 'native'].includes(capture)) throw new Error('capture must be sky or native');
545
+ if (backend === 'native' && !this.#native) throw new Error('Native UIA is not configured');
546
+ const nativeOnly = this.#window.nativeOnly === true;
547
+ if (nativeOnly) {
548
+ if (backend === 'sky') throw new Error('Sky does not list this window: observe it with the native backend');
549
+ if ((await this.#native.alive(this.#window)).state !== 'alive') throw new Error('Window identity changed; bind it again');
550
+ if (mode !== 'text') capture = 'native'; // aucune image Sky pour une fenêtre que Sky ne liste pas
551
+ }
552
+ const current = nativeOnly ? this.#window : await this.#sky.get_window({ id: this.#window.id, app: this.#window.app });
553
+ if (!sameWindow(current, this.#window)) throw new Error('Window identity changed; bind it again');
554
+ const useNative = nativeOnly || backend === 'native' || (backend === 'auto' && this.#nativePreferred);
555
+ // Capture propre (PrintWindow, fenêtre recouverte, sans premier plan) ; image vide ou échec : capture Sky.
556
+ let images = [], imageFallback = null;
557
+ if (capture === 'native' && mode !== 'text') {
558
+ if (!this.#native?.capture || !this.#root) imageFallback = 'Native capture is not configured';
559
+ else {
560
+ const directory = path.join(this.#root, 'captures');
561
+ try {
562
+ const shot = await this.#native.capture(current, directory);
563
+ if (shot.blank) imageFallback = 'PrintWindow returned a uniform (blank) image';
564
+ // 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 }];
567
+ } catch (error) { imageFallback = error.message; }
568
+ await this.#pruneCaptures(directory);
569
+ }
570
+ }
571
+ const skyText = mode !== 'image' && !useNative;
572
+ const skyImage = mode !== 'text' && !images.length && !nativeOnly;
573
+ const state = !skyText && !skyImage
574
+ ? { window: current, accessibility: null, screenshots: [] }
575
+ : await this.#sky.get_window_state({ window: current, include_text: skyText, include_screenshot: skyImage });
576
+ if (!sameWindow(state.window, current)) throw new Error('Capture returned another window');
577
+ this.#window = state.window;
578
+ if (this.#stack.length) this.#stack[this.#stack.length - 1] = state.window;
579
+ let accessibility = state.accessibility;
580
+ let elements = parseTree(accessibility?.tree);
581
+ const focusedIndex = Number(/^\s*(\d+)\s/.exec(accessibility?.focused_element ?? '')?.[1]);
582
+ elements = elements.map(e => ({ ...e, focused: Number.isFinite(focusedIndex) ? e.index === focusedIndex : e.flags.includes('focused') ? true : null }));
583
+ let source = 'sky';
584
+ const nativeRead = mode !== 'image' && this.#native?.inspect && (useNative || (!elements.length && backend === 'auto'));
585
+ let revision = nativeRead ? null : await this.#getRevision(state.window.app); // une lecture native donne la sienne
586
+ if (nativeRead) {
587
+ const native = await this.#native.inspect(state.window);
588
+ accessibility = native.accessibility; elements = native.elements; source = 'native-uia';
589
+ revision = native.revision;
590
+ this.#nativePreferred = true;
591
+ }
592
+ else if (mode !== 'image') this.#nativePreferred = false;
593
+ const observation = freeze({
594
+ token: randomUUID(), epoch: this.#coordinator.epoch, capturedAt: Date.now(), mode, source, revision,
595
+ window: clone(state.window), windowStack: this.#stack.map(w => w.id), windowChange: clone(windowChange), popups: clone(this.#popups),
596
+ elements, structureHash: structureHash(elements),
597
+ accessibility: clone(accessibility),
598
+ // The provider displays images. Never copy their pixels into memory or telemetry.
599
+ screenshots: (state.screenshots ?? []).map(({ id, width, height, originX, originY, zIndex }) =>
600
+ ({ id, width, height, originX, originY, zIndex })),
601
+ // Captures natives : fichiers locaux (LocalAppData) à regarder avec view_image ; jamais des cibles de coordonnées.
602
+ images, imageFallback,
603
+ });
604
+ this.#observation = observation;
605
+ return observation;
606
+ }
607
+ async observe({ mode = 'text', backend = 'auto', capture = 'sky' } = {}) {
608
+ return this.#exclusive(async () => {
609
+ const windowChange = await this.#follow(false);
610
+ return this.#capture(mode, backend, { capture, windowChange });
611
+ });
612
+ }
613
+ #take(observation) {
614
+ if (!observation || observation !== this.#observation || observation.epoch !== this.#coordinator.epoch) {
615
+ throw new Error('Stale or foreign observation; observe and inspect the current window again');
616
+ }
617
+ if (Date.now() - observation.capturedAt > this.maxAgeMs) {
618
+ this.#invalidate(); throw new Error('Observation expired; observe again');
619
+ }
620
+ return observation;
621
+ }
622
+ async inspectRecipe(name, observation) {
623
+ if (!this.#memory) throw new Error('Local application memory is not configured');
624
+ this.#take(observation);
625
+ const recipe = await this.#memory.read(observation.window.app, name);
626
+ this.#take(observation); // Disk I/O must not allow an interleaved action to reuse old state.
627
+ if (!recipe) return { status: 'unknown', name };
628
+ const revision = observation.source === 'native-uia' ? observation.revision : await this.#getRevision(observation.window.app);
629
+ this.#take(observation);
630
+ if (recipe.structureHash !== observation.structureHash || recipe.revision !== revision) {
631
+ return { status: 'changed', name, recipe, reason: 'Application binary or visible control structure changed; inspect and relearn' };
632
+ }
633
+ try { uniqueElement(observation.elements, recipe.selector); }
634
+ catch { return { status: 'changed', name, recipe, reason: 'Remembered target is absent, disabled or ambiguous' }; }
635
+ return { status: 'ready', name, recipe };
636
+ }
637
+ async act(observation, request) {
638
+ return this.#working(() => this.#exclusive(async () => {
639
+ // Toute action laisse une trace (journal commun) : un refus avant l'entrée, non noté plus bas, l'est ici
640
+ // (relecture de Codex du 23/10 : zéro métrique pour ces refus).
641
+ const noted = this.#metrics.length, start = Date.now();
642
+ try { return await this.#actOnce(observation, request); }
643
+ catch (error) {
644
+ if (this.#metrics.length === noted) this.#record(request?.kind, error?.refused || error?.beforeInput ? 'refused' : 'unknown', Date.now() - start);
645
+ throw error;
646
+ }
647
+ }));
648
+ }
649
+
650
+ // Lot d'actions vérifiées en un appel : chaque étape a sa postcondition, s'appuie sur l'observation rafraîchie par
651
+ // l'étape précédente (réobservation interne, une observation ne sert qu'une fois) et le lot s'arrête à la première
652
+ // étape non atteinte, inconnue, différée, interrompue par Julien ou refusée.
653
+ async actBatch(observation, steps) {
654
+ return this.#working(() => this.#exclusive(async () => {
655
+ if (!Array.isArray(steps) || !steps.length || steps.length > BATCH_MAX) throw new Error(`A batch needs 1 to ${BATCH_MAX} steps`);
656
+ for (const [i, step] of steps.entries()) {
657
+ // Étape rejetée avant le lot : notée au journal commun (rien n'est envoyé ; relecture de Codex).
658
+ try { validateAction(clone(step)); } catch (error) { this.#record(step?.kind, 'refused', 0); throw new Error(`Step ${i}: ${error.message}`); }
659
+ if (!step.expect) { this.#record(step?.kind, 'refused', 0); throw new Error(`Step ${i}: every batch step needs an explicit expect postcondition`); }
660
+ }
661
+ const report = [];
662
+ let current = observation;
663
+ // Bail d'interaction du lot : une étape suivante ne reprend jamais un nouveau jeton après sa clôture, même si la
664
+ // relecture précédente a rendu une observation utilisable (relecture de Codex, 21/10).
665
+ this.#batchLease = this.#interaction;
666
+ try {
667
+ for (const [index, step] of steps.entries()) {
668
+ const started = Date.now();
669
+ let result;
670
+ const noted = this.#metrics.length;
671
+ try { result = await this.#actOnce(current, step); }
672
+ catch (error) {
673
+ const status = error.emergencyStop ? 'stopped' : error.julienActive ? 'julien-active' : error.deferred ? 'deferred'
674
+ : error.confirmationRequired ? 'confirmation-required' : error.refused || error.beforeInput ? 'refused' : 'unknown';
675
+ // Étape refusée avant toute entrée et pas encore notée : journal commun aussi (relecture de Codex).
676
+ if (this.#metrics.length === noted) this.#record(step?.kind, status, Date.now() - started);
677
+ report.push({ index, kind: step.kind, status, error: error.message, durationMs: Date.now() - started });
678
+ const summary = freeze({ status: 'stopped', stoppedAt: index, completed: index, steps: clone(report), observation: null });
679
+ if (error.emergencyStop) { error.batch = summary; throw error; }
680
+ return summary;
681
+ }
682
+ report.push({ index, kind: step.kind, status: result.status, verified: result.verified, performed: result.performed,
683
+ technique: result.technique, foregroundTaken: result.foregroundTaken, foregroundReturned: result.foregroundReturned, julienInput: result.julienInput,
684
+ windowChange: result.windowChange, durationMs: result.durationMs });
685
+ current = result.observation;
686
+ if (!['verified', 'already-satisfied'].includes(result.status) || result.julienInput) {
687
+ return freeze({ status: 'stopped', stoppedAt: index, completed: index, steps: clone(report), observation: current });
688
+ }
689
+ }
690
+ return freeze({ status: 'completed', completed: steps.length, steps: clone(report), observation: current });
691
+ } finally { this.#batchLease = undefined; }
692
+ }));
693
+ }
694
+
695
+ // Postcondition jugée sur une observation, avant toute entrée (règles en tête de fichier). Vrai : déjà atteinte et
696
+ // précise (already-satisfied) ; faux : l'action est à faire ; refus (beforeInput) : la condition ne prouverait rien.
697
+ #judge(spec, observation, selected) {
698
+ const expect = spec.expect;
699
+ const elements = observation.elements;
700
+ const target = spec.kind === 'typeText' ? uniqueElement(elements, spec.focus) : selected;
701
+ const found = findElements(elements, expect.selector);
702
+ const onTarget = Boolean(target) && found.length === 1 && found[0].index === target.index;
703
+ if (onTarget && restatesTarget(expect, actionTarget(spec)) === 'its existence') throw beforeInput(TRIVIAL('its existence'));
704
+ // Liste déroulante : choix direct (select) ou option cliquée dans la liste ouverte.
705
+ const combo = !target ? null : spec.kind === 'select' && target.role === 'combobox' ? target
706
+ : spec.kind === 'click' && target.role === 'listitem'
707
+ ? target.ancestors.map(i => elements.find(e => e.index === i)).reverse().find(e => e?.role === 'combobox') ?? null : null;
708
+ if (combo) {
709
+ const option = spec.kind === 'select' ? spec.option : target.name;
710
+ const intoList = found.some(e => e.ancestors.includes(combo.index));
711
+ const byName = expect.selector.name !== undefined && label(expect.selector.name) === label(option) && [undefined, 'listitem'].includes(expect.selector.role);
712
+ if (intoList || byName) {
713
+ const comboSelector = combo.automationId ? { role: 'combobox', automationId: combo.automationId } : { role: 'combobox', name: combo.name };
714
+ throw beforeInput('A combo box choice is proven by the combo box value, not by its option element (exposed only while the list is open, ' +
715
+ `or always present depending on the provider): expect:${JSON.stringify({ selector: comboSelector, field: 'value', equals: option })}`);
716
+ }
717
+ }
718
+ if (!checkExpectation(elements, expect)) return false;
719
+ const precise = expect.equals !== undefined && expect.exists !== false && (!onTarget || STATE_FIELDS.has(expect.field ?? 'name'));
720
+ if (precise) return true;
721
+ throw beforeInput('Postcondition is already true before the action, so it cannot prove its effect (' +
722
+ (expect.equals === undefined ? 'existence or partial text' : "the target's label") + '). ' +
723
+ 'Use a precise state that this action changes (equals on value, checked, selected, expanded or focused, ' +
724
+ 'for example {field:\'expanded\', equals:true} for Expand), or a result on another element');
725
+ }
726
+ #already(observation, start, kind = null) {
727
+ this.#record(kind, 'already', Date.now() - start);
728
+ return freeze({ status: 'already-satisfied', verified: false, performed: false, technique: null, foregroundTaken: false,
729
+ attempts: [], julienInput: false, windowChange: null, durationMs: Date.now() - start, observation });
730
+ }
731
+ // Une entrée physique existe-t-elle pour cette action ? Observation Sky : indices Sky ; native : UIA sous jeton.
732
+ #canFallBack(spec, observation) {
733
+ if (['select', 'menu'].includes(spec.kind)) return false;
734
+ // Observation native : pas d'indice Sky pour remplacer une valeur ; clic sémantique par UIA sous jeton, frappe,
735
+ // touches et coordonnées par Sky comme avant.
736
+ if (observation.source === 'native-uia') return !['setValue', 'secondary'].includes(spec.kind);
737
+ return true;
738
+ }
739
+ #backgroundPlan(spec, before, selected, coordinate, screenshot) {
740
+ // Interaction en cours : la session tient déjà le jeton de premier plan ; le helper l'attendrait 8 s (autre
741
+ // processus) pour un point ou une touche postés. Ces gestes passent par l'entrée physique sous le bail existant,
742
+ // ou sont refusés avant toute entrée (relecture de Codex, 21/10).
743
+ const leased = Boolean(this.#actLease !== undefined ? this.#actLease : this.#interaction?.active);
744
+ if (coordinate && screenshot?.native && this.#native?.call && spec.kind !== 'drag') {
745
+ if (leased) return null;
746
+ // Point d'une capture native : clic (bouton, double) ou molette postés au contrôle sous le point ; le helper relit
747
+ // 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] };
750
+ if (spec.kind === 'click') Object.assign(args, { action: 'click', button: spec.button ?? 'left', count: spec.count ?? 1 });
751
+ else {
752
+ const notches = v => (v ? Math.sign(v) * Math.max(1, Math.round(Math.abs(v) / 120)) : 0);
753
+ Object.assign(args, { action: 'wheel', deltaY: -120 * notches(spec.scrollY ?? 0), deltaX: 120 * notches(spec.scrollX ?? 0) });
754
+ }
755
+ return { op: 'point', args };
756
+ }
757
+ if (coordinate || !this.#native?.call || !BACKGROUND_KINDS.has(spec.kind)) return null;
758
+ if (spec.kind === 'click' && ((spec.button ?? 'left') !== 'left' || (spec.count ?? 1) !== 1)) return null;
759
+ const native = before.source === 'native-uia';
760
+ const target = spec.kind === 'typeText' ? uniqueElement(before.elements, spec.focus) : selected;
761
+ const args = { mode: 'background' };
762
+ if (target) {
763
+ args.selector = spec.kind === 'typeText' ? spec.focus : spec.selector;
764
+ if (native) Object.assign(args, nativeIdentity(target, before));
765
+ }
766
+ if (spec.kind === 'setValue') args.value = spec.value;
767
+ if (spec.kind === 'typeText') args.text = spec.text;
768
+ if (spec.kind === 'secondary') args.action = spec.action;
769
+ if (spec.kind === 'select') args.option = spec.option;
770
+ if (spec.kind === 'menu') args.path = spec.path;
771
+ if (spec.kind === 'key') { if (leased) return null; args.key = spec.key; }
772
+ return { op: spec.kind, args };
773
+ }
774
+ // ---- Confirmation étanche (garde-fous.mjs) : avant toute entrée, quelle que soit la technique ensuite ----
775
+ // Arbre le plus riche disponible pour juger une touche ou un clic en coordonnées : lecture native fraîche (rectangles,
776
+ // focus, raccourcis Alt, bouton par défaut), sinon l'observation qui fonde l'action.
777
+ async #treeForConfirmation(before) {
778
+ if (before.source === 'native-uia' && Date.now() - before.capturedAt <= FRESH_MS) return before.elements;
779
+ if (!this.#native?.inspect) return before.elements;
780
+ try { return (await this.#native.inspect(before.window)).elements ?? before.elements; }
781
+ catch (error) {
782
+ if (error.refused || error.emergencyStop) throw error;
783
+ return before.elements;
784
+ }
785
+ }
786
+ // Destruction locale (désinstaller, formater…) : confirm:true seul. Geste SORTANT (envoyer, payer, publier…) : en
787
+ // plus, accord:"<jeton>" lié à l'action exacte (garde-fous.mjs, Accords), vérifié même avec confirm:true. Rend le
788
+ // jeton vérifié (à brûler juste avant l'entrée), ou null.
789
+ async #confirmation(spec, before, selected, screenshot) {
790
+ const policy = gardeFous.load(); // illisible : refus, rien n'est envoyé (échec fermé)
791
+ const title = before.window?.title ?? '';
792
+ const outward = []; // déclencheurs sortants de ce geste : « libellé — d'où [mot] »
793
+ const outwardLabels = new Set();
794
+ let basis = before.elements, what = '';
795
+ const fail = (labels) => {
796
+ for (const [label, source] of labels) {
797
+ const word = gardeFous.outwardWord(label, title, policy);
798
+ if (word) { outward.push(`${String(label).trim().slice(0, 120)}${source ? ` — ${source}` : ''} [${word}]`); outwardLabels.add(label); continue; }
799
+ if (spec.confirm === true) continue;
800
+ const refusal = gardeFous.check(what, [[label, source]], title, policy);
801
+ if (refusal) throw refusal;
802
+ }
803
+ };
804
+ const anchors = []; // éléments visés par le geste : leur zone (composeur, formulaire) est liée à l'accord
805
+ const clickOn = (element, elements) => {
806
+ anchors.push(element);
807
+ const trigger = gardeFous.triggerOf(element, elements, policy);
808
+ if (trigger) fail([[gardeFous.labelOf(trigger, elements), '']]);
809
+ };
810
+ const atPoints = async points => {
811
+ basis = await this.#treeForConfirmation(before);
812
+ const root = basis.find(e => !e.ancestors?.length);
813
+ for (const point of points) for (const [x, y] of gardeFous.screenPoints(point, screenshot, root)) {
814
+ // Point d'une capture native : le helper vise la fenêtre la plus haute sous le point (ordre Z), pas forcément
815
+ // l'élément le plus profond ; TOUS les éléments sous le point sont jugés (relecture de Codex).
816
+ const under = screenshot?.native ? gardeFous.elementsAt(basis, x, y) : [gardeFous.elementAt(basis, x, y)].filter(Boolean);
817
+ for (const hit of under) { hits++; clickOn(hit, basis); }
818
+ }
819
+ // Aucun élément (avec son rectangle) sous le point (lecture native trop grosse ou absente) : le geste est jugé comme
820
+ // s'il visait chaque bouton d'action de la fenêtre (libre s'il n'y en a aucun de sortant ou d'irréversible) ;
821
+ // sans aucun élément lu, refus : on ne sait rien de ce qu'il déclencherait.
822
+ if (hits) return;
823
+ if (!basis.length) throw beforeInput("Coordinate gesture: nothing is known under the point (no element read), so what it would trigger is unknown. Observe with mode:'both' or backend:'native', then act");
824
+ // Tout élément nommé qui porte un déclencheur compte (même rôle générique que pour un clic par sélecteur), et tous
825
+ // les documents de la fenêtre sont liés (corps d'un message sans zone connue).
826
+ what += ' (élément sous le point inconnu)';
827
+ blind = true;
828
+ const labels = new Set();
829
+ for (const e of basis) {
830
+ if (!e.name) continue;
831
+ const trigger = gardeFous.triggerOf(e, basis, policy);
832
+ if (trigger && !trigger.disabled) labels.add(gardeFous.labelOf(trigger, basis));
833
+ }
834
+ fail([...labels].map(label => [label, "élément de la fenêtre"]));
835
+ };
836
+ let hits = 0, blind = false;
837
+ let kinds = [];
838
+ switch (spec.kind) {
839
+ case 'click': {
840
+ if ((spec.button ?? 'left') !== 'left') return null;
841
+ if (selected) { what = spec.count === 2 ? 'double clic' : 'clic'; clickOn(selected, before.elements); break; }
842
+ what = 'clic par coordonnées';
843
+ await atPoints([spec.point]);
844
+ break;
845
+ }
846
+ // Appuyer puis relâcher sur un bouton (même point, ou glisser jusqu'à lui) le déclenche : départ et arrivée jugés.
847
+ case 'drag': what = 'glisser (départ et arrivée)'; await atPoints([spec.from, spec.to]); break;
848
+ case 'secondary': what = `action secondaire ${spec.action}`; if (!gardeFous.freeSecondary(spec.action)) clickOn(selected, before.elements); break;
849
+ case 'menu': what = 'menu'; fail([[spec.path.split('>').at(-1).trim(), '']]); break;
850
+ // Après Tab dans le même geste, le focus a bougé : Entrée ou Espace sont jugés sur tous les boutons d'action.
851
+ case 'key': {
852
+ kinds = gardeFous.keyKinds(spec.key); what = `touche « ${spec.key} » : déclencherait`;
853
+ if (/(^|\s)(shift\+)?tab(\s|$)/i.test(spec.key) && kinds.length) kinds = ['send'];
854
+ break;
855
+ }
856
+ case 'typeText':
857
+ // Une tabulation peut déplacer le focus (corps Gmail : Tab mène à Envoyer), même dans un document.
858
+ if (/\t[\s\S]*[ \r\n]/.test(spec.text)) { kinds = ['send']; what = 'tabulation puis espace ou saut de ligne dans le texte : déclencherait'; }
859
+ else if (/[\r\n]/.test(spec.text)) { kinds = ['submit']; what = 'saut de ligne dans le texte : déclencherait'; }
860
+ break;
861
+ default: return null;
862
+ }
863
+ let field = null;
864
+ if (kinds.length) {
865
+ basis = await this.#treeForConfirmation(before);
866
+ if (spec.kind === 'typeText') {
867
+ const matches = findElements(basis, spec.focus);
868
+ if (matches.length === 1) field = matches[0];
869
+ }
870
+ field ??= basis.filter(e => e.focused === true).sort((a, b) => b.depth - a.depth)[0] ?? null;
871
+ for (const kind of kinds) fail(gardeFous.keyTriggers(basis, field, kind, policy));
872
+ }
873
+ if (!outward.length) return null;
874
+ if (field) anchors.push(field);
875
+ // Ce que l'accord lie et montre (jamais un mot de passe) : valeurs des champs éditables ; un document (corps d'un
876
+ // mail) s'il est le champ visé ou dans la zone du déclencheur (son ancêtre à 3 niveaux, jamais la racine : une page
877
+ // ou le fil d'une messagerie hors de cette zone changent sans cesse). Destinataires à part : valeur d'un champ nommé
878
+ // À, To recipients, Cc, Courriel, IBAN…, et « pastilles » (adresse validée devenue bouton ou élément de liste) dans
879
+ // ce champ ou à côté de lui (même parent, jamais la racine), sauf les déclencheurs du geste ; un élément de trop y est
880
+ // lié en plus (sens sûr). Conversation (Teams, WhatsApp : le destinataire n'est pas un champ) : titre sans compteur,
881
+ // titres de section et éléments sélectionnés des listes (conversation ouverte).
882
+ const zoneOf = e => { const a = e.ancestors ?? []; const levels = Math.min(3, a.length - 1); return levels > 0 ? a[a.length - levels] : null; };
883
+ const zones = new Set(anchors.map(zoneOf).filter(z => z !== null));
884
+ const isRecipient = e => RECIPIENT_FIELD.test(plainName(e.name || e.automationId || ''));
885
+ const isEditable = e => ['edit', 'combobox'].includes(e.role) || (e.role === 'document' && (blind || e === field || e.ancestors?.some(i => zones.has(i))));
886
+ const chipHomes = new Set(basis.filter(isRecipient).flatMap(e => isEditable(e) && e.ancestors?.length > 1 ? [e.index, e.ancestors.at(-1)] : [e.index]));
887
+ const CHIP_ROLES = new Set(['button', 'listitem', 'link', 'menuitem', 'custom', 'unknown']);
888
+ const recipients = [], chips = [], content = [];
889
+ const conversation = [title.replace(/^\(\d+\+?\)\s*/, '').replace(/\s*\(\d+\+?\)$/, '')];
890
+ for (const e of basis) {
891
+ if (e.password) continue;
892
+ const name = e.name || e.automationId || 'champ';
893
+ if (isEditable(e)) {
894
+ if (!e.value) continue;
895
+ if (isRecipient(e)) recipients.push(e.value);
896
+ else content.push(`${name}: ${e.value}${e.valueTruncated ? ' […]' : ''}`);
897
+ } else if (e.name && CHIP_ROLES.has(e.role) && !isRecipient(e) && !anchors.includes(e) && !outwardLabels.has(e.name) &&
898
+ (chipHomes.has(e.ancestors?.at(-1)) || e.ancestors?.some(i => chipHomes.has(i) && basis.find(x => x.index === i && !isEditable(x) && isRecipient(x))))) {
899
+ chips.push(e.name);
900
+ } else if (e.name && (e.role === 'heading' || (e.selected === true && ['listitem', 'treeitem', 'tab', 'dataitem'].includes(e.role)))) {
901
+ conversation.push(e.name);
902
+ }
903
+ }
904
+ if (spec.kind === 'typeText') content.push(`typed: ${spec.text}`);
905
+ const act = gardeFous.action({ gesture: what, targets: outward, recipients, chips, conversation, content,
906
+ window: `${before.window?.app ?? ''}#${before.window?.id ?? ''}`, title });
907
+ const refusal = gardeFous.accords.check(act, spec.confirm, spec.accord, policy);
908
+ if (refusal) throw refusal;
909
+ return spec.accord;
910
+ }
911
+
912
+ // Contrôles propres à l'entrée physique, faits sur l'observation qui la fonde.
913
+ #physicalTarget(spec, original, basis) {
914
+ if (!this.#canFallBack(spec, basis)) {
915
+ throw beforeInput(basis.source === 'native-uia'
916
+ ? `No background technique applies, and a native observation has no Sky index for a physical ${spec.kind}`
917
+ : `No background technique applies for ${spec.kind}; open the control with a click and choose the item`);
918
+ }
919
+ if (spec.kind === 'click' && spec.selector && basis.source === 'native-uia' && ((spec.button ?? 'left') !== 'left' || (spec.count ?? 1) !== 1) &&
920
+ !this.#nativeInput()) {
921
+ throw beforeInput('Native semantic actions require one left click; observe a screenshot for other clicks');
922
+ }
923
+ let selected;
924
+ if (spec.selector) {
925
+ const current = uniqueElement(basis.elements, spec.selector);
926
+ const prior = uniqueElement(original.elements, spec.selector);
927
+ if (!equal(targetIdentity(prior, original.elements), targetIdentity(current, basis.elements))) {
928
+ throw beforeInput('Target context changed since inspection; inspect the new observation');
929
+ }
930
+ selected = current;
931
+ }
932
+ if (spec.kind === 'typeText') {
933
+ const focused = uniqueElement(basis.elements, spec.focus);
934
+ const prior = uniqueElement(original.elements, spec.focus);
935
+ if (!equal(targetIdentity(prior, original.elements), targetIdentity(focused, basis.elements))) {
936
+ throw beforeInput('Editable target context changed since inspection; observe again');
937
+ }
938
+ if (!['edit', 'document'].includes(focused.role)) throw beforeInput('Focus target is not an editable surface');
939
+ const focusIndex = Number(/^\s*(\d+)\s/.exec(basis.accessibility?.focused_element ?? '')?.[1]);
940
+ if (focusIndex !== focused.index) throw beforeInput('Observed editable surface does not have keyboard focus');
941
+ }
942
+ return selected;
943
+ }
944
+
945
+ // ---- Présence de Julien avant toute entrée physique ----
946
+ // Garde prévu (session réelle) mais présence inconnue (garde mort, en cours de relance) : attente brève de son retour
947
+ // (le contrôle d'arrêt le relance), puis refus différé. Jamais d'entrée physique sur une inactivité supposée.
948
+ async #knownPresence(presence) {
949
+ if (!this.#stop.guardExpected || presence?.available) return;
950
+ const sleep = this.#pause.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
951
+ for (let i = 0; i < PRESENCE_POLLS && !presence?.available; i++) {
952
+ this.#assertNotStopped();
953
+ await sleep(PRESENCE_POLL_MS);
954
+ }
955
+ if (!presence?.available) throw new PresenceUnknownError(this.#stop.guardState ?? 'absent');
956
+ }
957
+ // Bail d'interaction de l'action (figé à son entrée) expiré, remplacé ou jeton perdu : plus aucune entrée.
958
+ #leaseGone(lease = this.#actLease !== undefined ? this.#actLease : this.#interaction) {
959
+ if (lease?.held?.() === false) lease.finish?.('token-lost'); // helper du bail disparu : bail clos
960
+ return Boolean(lease && (!lease.active || lease.reason || this.#interaction !== lease || lease.held?.() === false)) ||
961
+ this.#tokenHeld?.() === false;
962
+ }
963
+ async #physicalInput(purpose, action, { token = true } = {}) {
964
+ const interaction = this.#actLease !== undefined ? this.#actLease : this.#interaction;
965
+ const leaseGone = () => this.#leaseGone(interaction);
966
+ if (leaseGone()) throw beforeInput('Foreground interaction ended before input');
967
+ const presence = this.#stop.presence;
968
+ let waitedMs = 0;
969
+ await this.#knownPresence(presence);
970
+ if (presence?.available) waitedMs = Math.round((await presence.waitForPause(this.#pause)).waitedMs);
971
+ this.#assertNotStopped();
972
+ if (leaseGone()) throw beforeInput('Foreground interaction ended before input'); // expirée pendant la pause
973
+ let t0 = 0;
974
+ const run = async () => {
975
+ if (leaseGone()) throw beforeInput('Foreground interaction ended before input');
976
+ // Le jeton peut avoir attendu : Julien a peut-être repris la main entre-temps.
977
+ if (presence?.available && presence.idleMs() < this.#pause.quietMs) throw new JulienActiveError(presence.idleMs());
978
+ const injection = presence?.beginInjection?.();
979
+ t0 = presence?.now?.() ?? 0;
980
+ try { return await action(presence); } finally { presence?.endInjection?.(injection); }
981
+ };
982
+ const report = {};
983
+ // Julien a repris clavier ou souris pendant l'entrée (présence, injections de l'agent exclues) : le helper garde son
984
+ // choix au lieu de remettre l'ancienne fenêtre devant.
985
+ const keep = () => Boolean(presence?.available && t0 && presence.since(t0));
986
+ const value = token ? await this.#foreground(purpose, run, report, keep) : await run();
987
+ return { value, waitedMs, presence: presence?.source ?? 'none', julienInput: Boolean(presence?.available && presence.since(t0)), ...report };
988
+ }
989
+ // Vraie souris et vrai clavier PROPRES au helper (Physical.cs, sans Sky ; décision de Julien du 22/10) : disponibles
990
+ // dès que le helper et son jeton de premier plan le sont. Sky ne sert plus qu'en secours (pas de helper).
991
+ #nativeInput() { return Boolean(this.#native?.call && this.#native?.withForeground); }
992
+ // Point d'écran de l'entrée physique native : capture native (origine du cadre + point) ou centre du rectangle d'une
993
+ // cible d'observation native ; null : pas de repère natif (Sky en secours).
994
+ #nativePoint(spec, basis, selected, screenshot, key = 'point') {
995
+ 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] };
998
+ }
999
+ if (basis.source === 'native-uia' && selected?.bounds?.length === 4 && selected.bounds[2] > 0 && selected.bounds[3] > 0) {
1000
+ const [x, y, w, h] = selected.bounds;
1001
+ return { x: Math.round(x + w / 2), y: Math.round(y + h / 2) };
1002
+ }
1003
+ return null;
1004
+ }
1005
+ #nativePhysical(purpose, basis, args, run) {
1006
+ return this.#physicalInput(purpose, presence => (run ? run(presence) : this.#native.call(basis.window, 'physical', args)))
1007
+ .then(r => ({ ...r, outcome: { technique: 'physical:native', mechanism: r.value?.mechanism ?? purpose, effect: 'sent', foregroundTaken: true,
1008
+ // Menu contextuel ouvert par CE vrai clic droit (helper), comme pour le clic droit posté.
1009
+ ...(r.value && Object.hasOwn(r.value, 'contextMenu') ? { contextMenu: r.value.contextMenu } : {}) } }));
1010
+ }
1011
+ // null : geste sans repère natif (Sky en secours) ; clic gauche simple sur une cible native : UIA au premier plan
1012
+ // (chemin existant, plus sûr qu'un point).
1013
+ async #runNativePhysical(spec, basis, selected, screenshot) {
1014
+ switch (spec.kind) {
1015
+ case 'click': {
1016
+ if (basis.source === 'native-uia' && selected && (spec.button ?? 'left') === 'left' && (spec.count ?? 1) === 1) return null;
1017
+ const click = { action: 'click', button: spec.button ?? 'left', count: spec.count ?? 1 };
1018
+ if (basis.source === 'native-uia' && selected && spec.selector) {
1019
+ // Le helper relit la cible (identité, ascendance, rectangle) APRÈS la mise au premier plan, juste avant l'entrée ;
1020
+ // aucun centre ancien n'est envoyé (relecture de Codex du 22/10 12:22).
1021
+ return this.#nativePhysical('native click', basis, { ...click, selector: spec.selector, ...nativeIdentity(selected, basis) });
1022
+ }
1023
+ const at = this.#nativePoint(spec, basis, null, screenshot);
1024
+ if (!at) return null;
1025
+ return this.#nativePhysical('native click', basis, { ...click, ...at });
1026
+ }
1027
+ case 'scroll': {
1028
+ const at = this.#nativePoint(spec, basis, null, screenshot);
1029
+ if (!at) return null;
1030
+ const notches = v => (v ? Math.sign(v) * Math.max(1, Math.round(Math.abs(v) / 120)) : 0);
1031
+ return this.#nativePhysical('native wheel', basis, { action: 'wheel', ...at, deltaY: -120 * notches(spec.scrollY ?? 0), deltaX: 120 * notches(spec.scrollX ?? 0) });
1032
+ }
1033
+ case 'drag': {
1034
+ const from = this.#nativePoint(spec, basis, null, screenshot, 'from');
1035
+ const to = this.#nativePoint(spec, basis, null, screenshot, 'to');
1036
+ if (!from || !to) return null;
1037
+ return this.#nativePhysical('native drag', basis, { action: 'drag', ...from, toX: to.x, toY: to.y });
1038
+ }
1039
+ case 'key': return this.#nativePhysical('native key', basis, { action: 'key', key: spec.key });
1040
+ case 'typeText': {
1041
+ // Frappe par tranches, comme avant : arrêt d'urgence, bail et présence de Julien contrôlés entre deux tranches ;
1042
+ // le reste n'est jamais envoyé après une interruption.
1043
+ const chunks = textChunks(spec.text, TYPE_CHUNK);
1044
+ const r = await this.#nativePhysical('native typeText', basis, null, async presence => {
1045
+ let typed = 0;
1046
+ for (const chunk of chunks) {
1047
+ if (typed > 0) {
1048
+ try { this.#stop.assertClear(); } catch { return { interrupted: 'emergency-stop', typed, total: chunks.length, mechanism: 'SendInput unicode text' }; }
1049
+ if (this.#leaseGone()) return { interrupted: 'interaction-ended', typed, total: chunks.length, mechanism: 'SendInput unicode text' };
1050
+ }
1051
+ const c0 = presence?.now?.() ?? 0;
1052
+ try { await this.#native.call(basis.window, 'physical', { action: 'type', text: chunk }); } catch (error) {
1053
+ // Tranches déjà envoyées : état partiel, accord consommé, jamais rendu ni rejoué (relecture de Codex 12:22).
1054
+ if (typed > 0 || error.afterInput) throw Object.assign(error, { afterInput: true, refused: false, beforeInput: false, notApplicable: false, deferred: false,
1055
+ typed: { chunks: typed, total: chunks.length, ...(error.typed ? { characters: error.typed } : {}) } });
1056
+ throw error;
1057
+ }
1058
+ typed++;
1059
+ if (typed < chunks.length && presence?.available && presence.since(c0)) return { interrupted: 'julien', typed, total: chunks.length, mechanism: 'SendInput unicode text' };
1060
+ }
1061
+ return { interrupted: null, typed, total: chunks.length, mechanism: 'SendInput unicode text' };
1062
+ });
1063
+ return { ...r, interrupted: r.value.interrupted, typed: { chunks: r.value.typed, total: r.value.total } };
1064
+ }
1065
+ default: return null;
1066
+ }
1067
+ }
1068
+ async #runPhysical(spec, basis, selected, screenshot) {
1069
+ const input = { window: basis.window };
1070
+ const nativeOnly = basis.window?.nativeOnly === true;
1071
+ if (this.#nativeInput()) {
1072
+ // « non applicable » du helper (ancien helper sans vraie entrée, geste qu'il ne sait pas faire) : rien n'a été
1073
+ // envoyé, Sky en secours ; toute autre erreur remonte telle quelle.
1074
+ try {
1075
+ const native = await this.#runNativePhysical(spec, basis, selected, screenshot);
1076
+ if (native) return native;
1077
+ } catch (error) {
1078
+ if (!error.notApplicable) throw error;
1079
+ // Point d'une capture native : Sky ne sait pas le viser ; refus, rien n'a été envoyé.
1080
+ if (screenshot?.native) throw Object.assign(new Error(`No technique applies at this point (${error.message}): nothing was sent`), { refused: true, beforeInput: true });
1081
+ }
1082
+ }
1083
+ // Fenêtre que Sky ne liste pas : aucune technique Sky, refus avant toute entrée (la voie UIA native du helper,
1084
+ // plus bas, reste permise : relecture de Codex).
1085
+ const noSky = () => { if (nativeOnly) throw beforeInput('No helper technique applies and Sky does not list this window: nothing was sent'); };
1086
+ const physical = (purpose, call) => (noSky(), this.#physicalInput(purpose, async presence => {
1087
+ // La capture precedente a rendu le premier plan a Julien. Ne jamais
1088
+ // envoyer une frappe ou un clic physique a cette application restituee.
1089
+ if (!this.#interaction?.active) await this.#sky.activate_window({ window: basis.window });
1090
+ this.#assertNotStopped();
1091
+ // Jeton ou bail perdu pendant l'activation : rien n'est envoyé (relecture de Codex, 21/10).
1092
+ if (this.#leaseGone()) throw beforeInput('Foreground token or interaction lost before input');
1093
+ return call(presence);
1094
+ }).then(r => ({ ...r, outcome: { technique: 'physical:sky', mechanism: purpose, effect: 'sent', foregroundTaken: true } })));
1095
+ switch (spec.kind) {
1096
+ case 'click': {
1097
+ if (basis.source === 'native-uia' && selected) {
1098
+ // Voie UIA au premier plan : un clic gauche SIMPLE seulement (Invoke, SetFocus) ; un clic droit ou double n'y est
1099
+ // jamais réduit (relecture de Codex du 22/10 13:57 : repli d'un clic droit en Invoke de « Envoyer »).
1100
+ if ((spec.button ?? 'left') !== 'left' || (spec.count ?? 1) !== 1) throw beforeInput('No technique keeps this right or double click on the native target: nothing was sent');
1101
+ if (this.#interaction?.active) throw beforeInput('Use observed coordinates for a foreground click during an interaction; native background actions remain available');
1102
+ let reply;
1103
+ try {
1104
+ // Le helper prend lui-même le jeton (jusqu'à 8 s) : il recontrôle la pause de Julien une fois le jeton pris,
1105
+ // juste avant Invoke ou SetFocus, et lui rend sa fenêtre sous le jeton.
1106
+ reply = await this.#physicalInput('UIA foreground', presence =>
1107
+ this.#native.call(basis.window, 'click', { selector: spec.selector, ...nativeIdentity(selected, basis), mode: 'foreground',
1108
+ ...(presence?.available ? { quietMs: this.#pause.quietMs } : {}) }), { token: false });
1109
+ } catch (error) {
1110
+ if (error.notApplicable) throw beforeInput('No background or foreground UI Automation technique applies: ' + error.message);
1111
+ throw error;
1112
+ }
1113
+ const r = reply.value ?? {};
1114
+ return { ...reply, foregroundReturned: r.foregroundReturned ?? null,
1115
+ outcome: { technique: r.technique ?? 'foreground:uia', mechanism: r.mechanism ?? null, effect: r.effect ?? 'sent', foregroundTaken: r.foregroundTaken ?? null } };
1116
+ }
1117
+ Object.assign(input, selected ? { element_index: selected.index } : { screenshotId: screenshot.id, x: spec.point.x, y: spec.point.y });
1118
+ Object.assign(input, { mouse_button: spec.button ?? 'left', click_count: spec.count ?? 1 });
1119
+ return physical('Sky click', () => this.#sky.click(input));
1120
+ }
1121
+ // Techniques sémantiques de Sky : UI Automation géré, qui peut activer la fenêtre ou lui donner le focus (mesure du
1122
+ // banc 1.3.0, voir Background.cs). Même règle qu'une entrée physique : pause de Julien, jeton, fenêtre rendue.
1123
+ case 'setValue': {
1124
+ noSky();
1125
+ const r = await this.#physicalInput('Sky set_value', () => this.#sky.set_value({ ...input, element_index: selected.index, value: spec.value }));
1126
+ return { ...r, outcome: { technique: 'sky:semantic', mechanism: 'set_value', effect: 'sent', foregroundTaken: null } };
1127
+ }
1128
+ case 'secondary': {
1129
+ noSky();
1130
+ if (!selected.actions.some(a => a.toLowerCase() === String(spec.action).toLowerCase())) throw beforeInput('Secondary action is not exposed by this control');
1131
+ const r = await this.#physicalInput('Sky secondary action', () => this.#sky.perform_secondary_action({ ...input, element_index: selected.index, action: spec.action }));
1132
+ return { ...r, outcome: { technique: 'sky:semantic', mechanism: 'perform_secondary_action', effect: 'sent', foregroundTaken: null } };
1133
+ }
1134
+ case 'typeText': {
1135
+ // Frappe graphème par graphème : avant chacun, l'arrêt d'urgence est contrôlé ; après chacun, la présence. Si
1136
+ // Julien appuie sur l'arrêt ou reprend clavier ou souris, la frappe s'arrête (le reste n'est jamais envoyé).
1137
+ const chunks = textChunks(spec.text, TYPE_CHUNK);
1138
+ const r = await physical('Sky typeText', async presence => {
1139
+ let typed = 0;
1140
+ for (const chunk of chunks) {
1141
+ if (typed > 0) {
1142
+ try { this.#stop.assertClear(); } catch { return { interrupted: 'emergency-stop', typed, total: chunks.length }; }
1143
+ if (this.#leaseGone()) return { interrupted: 'interaction-ended', typed, total: chunks.length };
1144
+ }
1145
+ const c0 = presence?.now?.() ?? 0;
1146
+ await this.#sky.type_text({ ...input, text: chunk });
1147
+ typed++;
1148
+ if (typed < chunks.length && presence?.available && presence.since(c0)) return { interrupted: 'julien', typed, total: chunks.length };
1149
+ }
1150
+ return { interrupted: null, typed, total: chunks.length };
1151
+ });
1152
+ return { ...r, interrupted: r.value.interrupted, typed: { chunks: r.value.typed, total: r.value.total } };
1153
+ }
1154
+ case 'key': return physical('Sky key', () => this.#sky.press_key({ ...input, key: spec.key }));
1155
+ case 'scroll': return physical('Sky scroll', () => this.#sky.scroll({ ...input, screenshotId: screenshot.id, x: spec.point.x, y: spec.point.y, scrollX: spec.scrollX ?? 0, scrollY: spec.scrollY ?? 0 }));
1156
+ case 'drag': return physical('Sky drag', () => this.#sky.drag({ ...input, screenshotId: screenshot.id, from_x: spec.from.x, from_y: spec.from.y, to_x: spec.to.x, to_y: spec.to.y }));
1157
+ default: throw beforeInput(`No physical technique for ${spec.kind}`);
1158
+ }
1159
+ }
1160
+ // Relecture après l'entrée : fenêtres liées suivies, puis attente de la postcondition (lectures seulement).
1161
+ #closed() {
1162
+ this.#invalidate();
1163
+ const observation = freeze({
1164
+ token: randomUUID(), epoch: this.#coordinator.epoch, capturedAt: Date.now(), mode: 'text', source: 'native-alive',
1165
+ revision: null, window: clone(this.#window), windowStack: [this.#window.id], windowChange: { reason: 'closed', from: this.#window.id, to: null },
1166
+ popups: [], elements: [], structureHash: structureHash([]), accessibility: null, screenshots: [], images: [],
1167
+ imageFallback: null, closed: true,
1168
+ });
1169
+ this.#observation = observation;
1170
+ return observation;
1171
+ }
1172
+ async #settle(spec, coordinate) {
1173
+ const refresh = spec.refresh ?? (coordinate ? 'both' : 'text');
1174
+ // Fenêtre liée fermée par l'action (Alt+F4, bouton Fermer, dialogue qui ferme l'application) : état terminal relu
1175
+ // par le helper à toute profondeur de pile, avant chaque capture et après une capture en échec, sans recapturer le
1176
+ // HWND détruit (passe 2 du 22/10, relecture de Codex). Existence illisible : la capture décide, comme avant.
1177
+ const rootGone = async () => {
1178
+ if (!this.#native?.alive || !this.#stack.length) return false;
1179
+ let state = null;
1180
+ try { state = (await this.#native.alive(this.#stack[0])).state; } catch { /* inconnu */ }
1181
+ return state === 'gone' || state === 'other';
1182
+ };
1183
+ const closed = () => {
1184
+ const from = this.#stack[0].id;
1185
+ this.#stack = [this.#stack[0]];
1186
+ this.#window = this.#stack[0];
1187
+ return { after: this.#closed(), change: { reason: 'closed', from, to: null } };
1188
+ };
1189
+ // Suivi et capture en échec : la racine relue d'abord (gone|other : terminal) ; sinon, une fenêtre du dessus
1190
+ // disparue pendant sa capture est dépilée par un nouveau suivi (lectures seules, preuve alive) et le propriétaire
1191
+ // recapturé une fois ; jamais de rejeu de l'action. Erreur d'existence : l'erreur d'origine remonte (inconnu).
1192
+ const GONE = Symbol('gone');
1193
+ let recovered = null; // fermeture du dessus relue pendant une capture : c'est le changement de fenêtre de l'action
1194
+ const follow = async () => {
1195
+ try { return await this.#follow(true); }
1196
+ catch (error) { if (await rootGone()) return GONE; throw error; }
1197
+ };
1198
+ const guarded = async (mode, windowChange) => {
1199
+ if (await rootGone()) return null;
1200
+ try { return await this.#capture(mode, 'auto', { windowChange }); }
1201
+ catch (error) { if (await rootGone()) return null; throw error; }
1202
+ };
1203
+ const capture = async (mode, windowChange) => {
1204
+ recovered = null; // propre à CETTE lecture : un ancien dépilage n'écrase jamais un suivi plus récent
1205
+ if (await rootGone()) return null;
1206
+ try { return await this.#capture(mode, 'auto', { windowChange }); }
1207
+ catch (error) {
1208
+ if (await rootGone()) return null;
1209
+ const top = this.#window?.id;
1210
+ const popped = this.#stack.length > 1 ? await follow() : null;
1211
+ if (popped === GONE) return null;
1212
+ // Recapture unique du propriétaire, elle aussi gardée par la relecture de la racine (relecture de Codex).
1213
+ if (popped?.reason === 'closed' && this.#window?.id !== top) { recovered = popped; return guarded(mode, popped); }
1214
+ throw error;
1215
+ }
1216
+ };
1217
+ let change = await follow();
1218
+ if (change === GONE) return closed();
1219
+ let after = await capture(spec.expect && refresh === 'image' ? 'both' : refresh, change);
1220
+ if (!after) return closed();
1221
+ if (recovered) change = recovered;
1222
+ if (spec.expect && !checkExpectation(after.elements, spec.expect)) {
1223
+ const deadline = Date.now() + (spec.timeoutMs ?? 1618);
1224
+ while (Date.now() < deadline && !checkExpectation(after.elements, spec.expect)) {
1225
+ await sleep(100);
1226
+ const followed = await follow();
1227
+ if (followed === GONE) return closed();
1228
+ change = followed ?? change;
1229
+ after = await capture('text', change); // Wait on state only. Never repeat the input.
1230
+ if (!after) return closed();
1231
+ if (recovered) change = recovered;
1232
+ }
1233
+ }
1234
+ return { after, change };
1235
+ }
1236
+
1237
+ async #actOnce(observation, request) {
1238
+ const start = Date.now();
1239
+ try { this.#assertNotStopped(); }
1240
+ catch (error) { this.#record(request?.kind ?? 'unknown', 'stopped', Date.now() - start); throw error; }
1241
+ // Bail d'interaction sur lequel l'action est fondée, figé AVANT toute attente (capture, fenêtre, confirmation,
1242
+ // fenêtres liées) : une expiration pendant l'une d'elles ne fait jamais prendre un NOUVEAU bail pour envoyer
1243
+ // (relecture de Codex, 21/10).
1244
+ this.#actLease = this.#batchLease !== undefined ? this.#batchLease : this.#interaction;
1245
+ const original = this.#take(observation);
1246
+ const spec = clone(request);
1247
+ validateAction(spec);
1248
+ const coordinate = spec.point !== undefined || spec.kind === 'scroll' || spec.kind === 'drag';
1249
+ if (spec.selector && coordinate) throw new Error('Choose a semantic selector or screenshot coordinates');
1250
+ if (spec.kind === 'typeText' && !spec.focus) throw new Error('typeText requires a focus selector from the observed editable surface');
1251
+ let selected;
1252
+ if (spec.selector) selected = uniqueElement(original.elements, spec.selector);
1253
+ if (['setValue', 'secondary', 'select'].includes(spec.kind) && !selected) throw new Error('A semantic target is required');
1254
+ if (spec.kind === 'click' && !selected && !coordinate) throw new Error('Click requires an observed semantic or coordinate target');
1255
+ if (spec.kind === 'typeText') uniqueElement(original.elements, spec.focus);
1256
+ if (['select', 'menu'].includes(spec.kind) && !this.#native?.call) throw new Error(`${spec.kind} needs the native helper (messages or UI Automation in background)`);
1257
+ if (spec.kind === 'secondary' && !selected.actions.some(a => a.toLowerCase() === String(spec.action).toLowerCase())) {
1258
+ throw new Error('Secondary action is not exposed by this control');
1259
+ }
1260
+ if (spec.expect && this.#judge(spec, original, selected)) return this.#already(original, start, spec.kind);
1261
+ let screenshot;
1262
+ if (coordinate) {
1263
+ screenshot = original.screenshots.find(s => s.id === spec.screenshotId) ?? nativeShot(original, spec.screenshotId);
1264
+ if (!screenshot) throw new Error('Choose a screenshotId from the inspected observation');
1265
+ // Glisser au point d'une capture native : seulement par la vraie souris du helper (relecture de Codex du 22/10 12:22).
1266
+ if (screenshot.native && !['click', 'scroll', ...(this.#nativeInput() ? ['drag'] : [])].includes(spec.kind)) throw new Error('A native capture point supports click and scroll only');
1267
+ // Pixel entier fixé AVANT la confirmation : c'est exactement lui qui sera jugé puis envoyé (relecture de Codex :
1268
+ // 109,6 jugé sur « Enregistrer », 110 envoyé sur « Envoyer »).
1269
+ const whole = p => p && { x: Math.floor(p.x), y: Math.floor(p.y) };
1270
+ if (screenshot.native && spec.kind === 'drag') Object.assign(spec, { from: whole(spec.from), to: whole(spec.to) });
1271
+ else if (screenshot.native) spec.point = whole(spec.point);
1272
+ const points = spec.kind === 'drag' ? [spec.from, spec.to] : [spec.point];
1273
+ for (const p of points) {
1274
+ if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y) || p.x < 0 || p.y < 0 ||
1275
+ (screenshot.width !== undefined && p.x >= screenshot.width) ||
1276
+ (screenshot.height !== undefined && p.y >= screenshot.height)) throw new Error('Point is outside the observed screenshot');
1277
+ }
1278
+ }
1279
+ let before = original;
1280
+ // Observation native lue il y a moins de FRESH_MS (celle que l'étape précédente d'un lot vient de relire) : pas de
1281
+ // seconde lecture complète juste avant l'entrée, elle n'apprendrait rien de plus ; le helper relit de toute façon la
1282
+ // cible et ses ancêtres (identité d'exécution, chaîne, activation, mot de passe) au moment de l'entrée.
1283
+ const fresh = original.source === 'native-uia' && Date.now() - original.capturedAt <= FRESH_MS &&
1284
+ !spec.requiredStructureHash && !Object.hasOwn(spec, 'requiredRevision');
1285
+ if (!coordinate && !fresh) {
1286
+ // Sky's Windows indexed click uses screen geometry even with an AX target.
1287
+ // A text-only refresh clears that geometry. Native UIA patterns do not need it.
1288
+ before = await this.#capture(original.source === 'sky' && spec.kind === 'click' ? 'both' : 'text');
1289
+ if (before.source !== original.source) throw new Error('Observation provider changed; inspect the new controls before input');
1290
+ if (spec.requiredStructureHash && spec.requiredStructureHash !== before.structureHash) {
1291
+ throw new Error('Remembered UI structure changed immediately before input; inspect and relearn');
1292
+ }
1293
+ if (Object.hasOwn(spec, 'requiredRevision') && spec.requiredRevision !== before.revision) {
1294
+ throw new Error('Application binary changed immediately before input; inspect and relearn');
1295
+ }
1296
+ if (selected) {
1297
+ const current = uniqueElement(before.elements, spec.selector);
1298
+ if (!equal(targetIdentity(selected, original.elements), targetIdentity(current, before.elements))) {
1299
+ throw new Error('Target context changed since inspection; inspect the new observation');
1300
+ }
1301
+ selected = current;
1302
+ }
1303
+ if (spec.kind === 'typeText') {
1304
+ const focused = uniqueElement(before.elements, spec.focus);
1305
+ const priorFocus = uniqueElement(original.elements, spec.focus);
1306
+ if (!equal(targetIdentity(priorFocus, original.elements), targetIdentity(focused, before.elements))) {
1307
+ throw new Error('Editable target context changed since inspection; observe again');
1308
+ }
1309
+ if (!['edit', 'document'].includes(focused.role)) throw new Error('Focus target is not an editable surface');
1310
+ }
1311
+ if (spec.expect && this.#judge(spec, before, selected)) return this.#already(before, start, spec.kind);
1312
+ } else if (coordinate) {
1313
+ if (before.window.nativeOnly) {
1314
+ if ((await this.#native.alive(before.window)).state !== 'alive') throw new Error('Coordinate target identity changed');
1315
+ } else {
1316
+ const current = await this.#sky.get_window({ id: before.window.id, app: before.window.app });
1317
+ if (!sameWindow(current, before.window)) throw new Error('Coordinate target identity changed');
1318
+ }
1319
+ }
1320
+ if (spec.kind === 'click' && selected && hiddenOffscreen(selected, before.elements)) {
1321
+ throw beforeInput('Target is off-screen (IsOffscreen) with no visible part in its window: a click would act on what Julien cannot see. ' +
1322
+ 'Scroll it into view, observe again, then act (visible first)');
1323
+ }
1324
+ // Confirmation étanche : refus avant toute entrée et avant toute consommation (rien n'a été fait).
1325
+ let accord;
1326
+ try { accord = await this.#confirmation(spec, before, selected, screenshot); }
1327
+ catch (error) { if (error.refused) this.#record(spec.kind, error.confirmationRequired ? 'confirmation-required' : 'refused', Date.now() - start); throw error; }
1328
+ const background = this.#backgroundPlan(spec, before, selected, coordinate, screenshot);
1329
+ // Sans technique d'arrière-plan possible, les contrôles de l'entrée physique précèdent toute consommation.
1330
+ if (!background) selected = this.#physicalTarget(spec, original, before) ?? selected;
1331
+ await this.#markSeen();
1332
+ // Jeton d'accord brûlé au dernier moment (deux appels ne peuvent pas l'utiliser tous deux) ; rendu plus bas si
1333
+ // l'entrée est refusée avant tout envoi.
1334
+ const spent = accord ? gardeFous.accords.spend(accord) : null;
1335
+ if (accord && !spent) throw Object.assign(new Error('Accord already used by another action: nothing was done; observe again'), { refused: true, beforeInput: true });
1336
+ this.#invalidate(); // Consume BEFORE sending input, including input with unknown outcomes.
1337
+ const attempts = [];
1338
+ let outcome = null, after, change = null, physical = null, unconfirmed = false;
1339
+ try {
1340
+ this.#assertNotStopped(); // Dernier point de contrôle avant l'entrée (après la relecture).
1341
+ // Action fondée sur une interaction expirée pendant sa préparation : ni geste posté ni nouveau jeton.
1342
+ if (this.#actLease && this.#leaseGone(this.#actLease)) throw beforeInput('Foreground interaction ended before input');
1343
+ if (background) {
1344
+ try {
1345
+ const reply = (await this.#native.call(before.window, background.op, background.args)) ?? {};
1346
+ outcome = { technique: reply.technique ?? 'background:uia', mechanism: reply.mechanism ?? null, effect: reply.effect ?? 'sent',
1347
+ foregroundTaken: reply.foregroundTaken === true, cursorMoved: reply.cursorMoved === true,
1348
+ // Premier plan rendu par le helper après un geste posté qui l'a fait prendre (Background.cs) : jusqu'au résultat.
1349
+ ...(typeof reply.foregroundReturned === 'string' ? { foregroundReturned: reply.foregroundReturned } : {}),
1350
+ // Diagnostic du geste au point (Pointer.LastTrace) et menu contextuel ouvert par CE clic droit (Background.cs) :
1351
+ // gardés jusqu'au résultat, sinon perdus (relecture de Codex du 22/10 07:13).
1352
+ ...(reply.trace ? { trace: reply.trace } : {}), ...(Object.hasOwn(reply, 'contextMenu') ? { contextMenu: reply.contextMenu } : {}) };
1353
+ attempts.push({ technique: outcome.technique, mechanism: outcome.mechanism, effect: outcome.effect });
1354
+ } catch (error) {
1355
+ if (!error.notApplicable) throw error;
1356
+ attempts.push({ technique: 'background', outcome: 'not-applicable', reason: error.message });
1357
+ }
1358
+ }
1359
+ if (outcome) {
1360
+ ({ after, change } = await this.#settle(spec, coordinate));
1361
+ // Repli physique seulement après un « none » RELU par le helper : rien n'a pris, rien n'est parti. Après « sent »
1362
+ // (transmis sans relecture possible), un état encore inchangé ne prouve rien : l'effet peut être lent (revue du
1363
+ // 16/10 : effet arrivé à 2,5 s, après la fenêtre de 1,618 s ; le repli refaisait un vrai clic, double action
1364
+ // rapportée « verified »). Ce cas reste « sent-unconfirmed » (envoyé, effet non relu) : jamais vérifié ni rejoué.
1365
+ if (spec.expect && !checkExpectation(after.elements, spec.expect)) {
1366
+ if (outcome.effect === 'none' && spec.fallback !== false && this.#canFallBack(spec, before)) {
1367
+ attempts.at(-1).outcome = 'no-effect';
1368
+ const basis = after.source === 'sky' && spec.kind === 'click' && after.mode === 'text' ? await this.#capture('both') : after;
1369
+ this.#invalidate();
1370
+ selected = this.#physicalTarget(spec, original, basis);
1371
+ before = basis; outcome = null;
1372
+ } else if (outcome.effect === 'sent' && !change && after.window.id === before.window.id && after.source === before.source &&
1373
+ after.structureHash === before.structureHash && stateDigest(after.elements) === stateDigest(before.elements)) {
1374
+ attempts.at(-1).outcome = 'effect-not-read';
1375
+ unconfirmed = true;
1376
+ }
1377
+ }
1378
+ }
1379
+ if (!outcome) {
1380
+ // fallback:false interdit TOUTE entrée physique, y compris quand aucune technique d'arrière-plan ne s'applique
1381
+ // (relecture de Codex du 21/10 : seul le repli après « none » le contrôlait). Rien n'a été envoyé.
1382
+ if (screenshot?.native && !this.#nativeInput()) {
1383
+ // Ni capture Sky ni vraie entrée du helper pour viser ce point : refus, rien n'a été envoyé.
1384
+ throw Object.assign(new Error('No background technique applies at this point; physical input needs a Sky screenshotId: nothing was sent'),
1385
+ { refused: true, beforeInput: true });
1386
+ }
1387
+ if (spec.fallback === false) {
1388
+ throw Object.assign(new Error('No background technique applies and fallback:false forbids physical input: nothing was sent'),
1389
+ { refused: true, beforeInput: true });
1390
+ }
1391
+ if (background && attempts.at(-1)?.outcome === 'not-applicable') selected = this.#physicalTarget(spec, original, before) ?? selected;
1392
+ physical = await this.#runPhysical(spec, before, selected, screenshot);
1393
+ outcome = physical.outcome;
1394
+ attempts.push({ technique: outcome.technique, mechanism: outcome.mechanism, effect: outcome.effect });
1395
+ ({ after, change } = await this.#settle(spec, coordinate));
1396
+ }
1397
+ // Helper du bail perdu pendant l'envoi ou la relecture (fond compris) : l'entrée a pu partir sans jeton ; inconnue
1398
+ // dès cette action, jamais « verified » (relecture de Codex, 21/10).
1399
+ // Bail clos pendant l'action : sa sortie (accusé du helper ou perte) est attendue avant de juger.
1400
+ if (this.#actLease?.reason) await this.#actLease.done;
1401
+ if (this.#actLease?.held?.() === false) throw new Error('Foreground token lost during the action; outcome unknown, do not replay');
1402
+ } catch (cause) {
1403
+ this.#invalidate();
1404
+ const interaction = this.#interaction;
1405
+ if (interaction?.active) { interaction.finish('input-failed'); await interaction.done; }
1406
+ // Arrêt d'urgence APRÈS une entrée partielle (frappe Chromium) : l'entrée compte, l'accord reste consommé.
1407
+ if (cause.afterInput) attempts.push({ technique: 'background', outcome: 'partial', typed: cause.typed ?? null });
1408
+ cause.attempts ??= attempts;
1409
+ if (!cause.afterInput && !attempts.some(a => a.outcome !== 'not-applicable') && (cause.emergencyStop || cause.deferred || cause.refused || cause.beforeInput)) gardeFous.accords.restore(spent);
1410
+ if (cause.emergencyStop) { this.#record(spec.kind, 'stopped', Date.now() - start); throw cause; }
1411
+ if (cause.julienActive) { this.#record(spec.kind, 'julien-active', Date.now() - start); throw cause; }
1412
+ if (cause.deferred) { this.#record(spec.kind, 'deferred', Date.now() - start); throw cause; }
1413
+ if (cause.refused || cause.beforeInput) { this.#record(spec.kind, 'refused', Date.now() - start); throw cause; }
1414
+ // Entrée partie puis relecture en échec : technique et premier plan attestés gardés (relecture de Codex).
1415
+ this.#record(spec.kind, 'unknown', Date.now() - start, outcome?.technique ?? null, outcome?.foregroundTaken === true);
1416
+ throw new Error('Input or refresh outcome is unknown. Observe again before deciding; do not replay automatically.', { cause });
1417
+ }
1418
+ const verified = Boolean(spec.expect && checkExpectation(after.elements, spec.expect));
1419
+ const status = physical?.interrupted ? 'interrupted' : verified ? 'verified' : unconfirmed ? 'sent-unconfirmed' : spec.expect ? 'unmet' : 'observed';
1420
+ const result = freeze({
1421
+ status, verified: verified && !physical?.interrupted, performed: true, technique: outcome.technique, mechanism: outcome.mechanism,
1422
+ foregroundTaken: outcome.foregroundTaken, attempts, julienInput: Boolean(physical?.julienInput),
1423
+ ...(physical && Object.hasOwn(physical, 'foregroundReturned') ? { foregroundReturned: physical.foregroundReturned }
1424
+ : Object.hasOwn(outcome, 'foregroundReturned') ? { foregroundReturned: outcome.foregroundReturned } : {}),
1425
+ ...(!physical && outcome.trace ? { trace: outcome.trace } : {}),
1426
+ ...(Object.hasOwn(outcome, 'contextMenu') ? { contextMenu: outcome.contextMenu } : {}),
1427
+ ...(physical?.interrupted ? { interrupted: physical.interrupted, typed: physical.typed } : {}),
1428
+ ...(physical ? { presence: physical.presence ?? 'none', waitedMs: physical.waitedMs ?? 0 } : {}),
1429
+ windowChange: change, durationMs: Date.now() - start, observation: after,
1430
+ });
1431
+ this.#record(spec.kind, status, result.durationMs, outcome.technique, outcome.foregroundTaken === true);
1432
+ this.#results.set(result, { before, spec });
1433
+ return result;
1434
+ }
1435
+ // Une routine apprise n'est jamais un ordre : un geste à confirmer exige `confirm:true`, sur l'ordre de Julien.
1436
+ async runRecipe(observation, name, { confirm, accord } = {}) {
1437
+ this.#assertNotStopped();
1438
+ const inspected = await this.inspectRecipe(name, observation);
1439
+ if (inspected.status !== 'ready') throw new Error(`Recipe ${name} is ${inspected.status}; inspect current controls and relearn after verification`);
1440
+ const r = inspected.recipe;
1441
+ return this.act(observation, { kind: r.kind, selector: r.selector, ...(r.kind === 'secondary' ? { action: r.action } : {}), expect: r.expect, requiredStructureHash: r.structureHash, requiredRevision: r.revision, ...(confirm === true ? { confirm: true } : {}), ...(accord !== undefined ? { accord } : {}) });
1442
+ }
1443
+ async remember(name, result, { staticUI = false } = {}) {
1444
+ if (!this.#memory) throw new Error('Local application memory is not configured');
1445
+ const evidence = this.#results.get(result);
1446
+ if (!evidence || !result.verified || !result.performed) throw new Error('Only a verified transition from this session can be remembered');
1447
+ const { before, spec } = evidence;
1448
+ if (!staticUI) throw new Error('Confirm static UI labels only; never persist user content or typed text');
1449
+ if (!['click', 'secondary'].includes(spec.kind) || !spec.selector || (spec.button && spec.button !== 'left') || (spec.count && spec.count !== 1)) {
1450
+ throw new Error('Memory supports single semantic actions, never coordinates, keystrokes or typed content');
1451
+ }
1452
+ const prior = await this.#memory.read(before.window.app, name);
1453
+ await this.#memory.save(before.window.app, name, {
1454
+ kind: spec.kind, selector: spec.selector, ...(spec.action ? { action: spec.action } : {}), expect: spec.expect,
1455
+ structureHash: before.structureHash, revision: before.revision,
1456
+ verifiedAt: new Date().toISOString(), verifiedCount: (prior?.verifiedCount ?? 0) + 1,
1457
+ });
1458
+ return { name, status: 'learned', verified: true };
1459
+ }
1460
+ #record(kind, status, durationMs, technique = null, foreground = null) {
1461
+ this.#metrics.push({ kind, status, technique, durationMs, at: new Date().toISOString() });
1462
+ // Journal commun des trois Nexus Windows (shared/journal/FORMAT.md) : sans texte d'écran, jamais bloquant.
1463
+ try { writeJournal(journalLine({ session: this.#sessionId, app: this.#window?.app, kind, status, technique, durationMs, foreground })); }
1464
+ catch { /* journal : jamais bloquant */ }
1465
+ }
1466
+ metrics() { return clone(this.#metrics); }
1467
+ async saveMetrics() {
1468
+ if (!this.#root) throw new Error('Local state is not configured');
1469
+ const dir = path.join(this.#root, 'reports');
1470
+ await mkdir(dir, { recursive: true });
1471
+ const file = path.join(dir, this.#sessionId + '.json');
1472
+ const tmp = file + '.' + randomUUID() + '.tmp';
1473
+ try {
1474
+ await writeFile(tmp, JSON.stringify({ schema: 2, sessionId: this.#sessionId, actions: this.#metrics }, null, 2), { flag: 'wx' });
1475
+ await rename(tmp, file);
1476
+ } finally {
1477
+ await unlink(tmp).catch(e => { if (e.code !== 'ENOENT') throw e; });
1478
+ }
1479
+ return file;
1480
+ }
1481
+ }
1482
+
1483
+ export function summarize(observation) {
1484
+ return {
1485
+ token: observation.token, window: observation.window, mode: observation.mode, source: observation.source,
1486
+ windowStack: observation.windowStack, windowChange: observation.windowChange, popups: observation.popups,
1487
+ tree: observation.accessibility?.tree ?? '', focused: observation.accessibility?.focused_element ?? '',
1488
+ screenshots: observation.screenshots, images: observation.images, imageFallback: observation.imageFallback,
1489
+ };
1490
+ }