@melaya/runner 1.1.73 → 1.1.75

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,179 @@
1
+ export type PauseRec = Record<string, unknown>;
2
+ export interface PauseRect {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ }
8
+ /** Where a run stopped / continues: batch step, then stroke / point / frac inside a path (contract ComputerResumePoint). */
9
+ export interface PausePosition {
10
+ step?: number;
11
+ stroke?: number;
12
+ point?: number;
13
+ frac?: number;
14
+ }
15
+ /** How far a cancelled path / drag got, as the helper reports it (contract ComputerPathProgress). */
16
+ export interface PauseProgress {
17
+ stroke?: number;
18
+ point?: number;
19
+ frac?: number;
20
+ pressed?: boolean;
21
+ }
22
+ /** What the pauses of one command did (contract ComputerPauseReport). */
23
+ export interface PauseReport {
24
+ count: number;
25
+ pausedMs: number;
26
+ refocused?: number;
27
+ moved?: boolean;
28
+ resumedStrokes?: number;
29
+ }
30
+ /** The part of a ComputerResult the engine reads. */
31
+ export interface PauseResult {
32
+ ok: boolean;
33
+ kind?: string;
34
+ text?: string;
35
+ error?: {
36
+ code?: string;
37
+ message?: string;
38
+ };
39
+ [k: string]: unknown;
40
+ }
41
+ export interface PauseWindow {
42
+ windowId: string;
43
+ appId?: string;
44
+ title?: string;
45
+ bounds: PauseRect;
46
+ focused?: boolean;
47
+ minimized?: boolean;
48
+ }
49
+ export declare const PAUSE_DEFAULTS: {
50
+ /** The user must be idle this long before the run goes on. */
51
+ readonly quietMs: 1500;
52
+ /** One pause lasts at most this long; then the run stops with user_interrupted and a resume point. */
53
+ readonly maxPauseMs: 60000;
54
+ /** Pause time the server grants per command on top of its deadline. */
55
+ readonly budgetMs: 180000;
56
+ /** A step interrupted more often than this stops (the user keeps taking over). */
57
+ readonly maxResumesPerStep: 20;
58
+ /** Time kept back before the deadline: a pause ends in time to report where it stopped. */
59
+ readonly reserveMs: 1500;
60
+ };
61
+ /** Reads that send no input: they run while the user is busy. Everything else waits until the user is idle. */
62
+ export declare const PAUSE_PASSIVE_KINDS: ReadonlySet<string>;
63
+ /** Pointer streams cancelled the moment the user takes over (never fight for the cursor); short atomic writes finish. */
64
+ export declare const PAUSE_CANCEL_KINDS: ReadonlySet<string>;
65
+ export declare function pauseNeedsIdle(kind: string): boolean;
66
+ export declare function pauseRectMoved(a: PauseRect, b: PauseRect, tol?: number): boolean;
67
+ export declare function pauseRectResized(a: PauseRect, b: PauseRect, tol?: number): boolean;
68
+ type PausePt = number[];
69
+ /**
70
+ * The rest of one stroke from (point, frac): it starts on the last sample the pointer reached (pressed again there,
71
+ * one sample of overlap, no gap, nothing drawn twice beyond it), then every later point; times are shifted to start at
72
+ * 0. null when nothing is left (the stroke had reached its last point).
73
+ */
74
+ export declare function pauseTrimStroke(stroke: PausePt[], point: number, frac: number): PausePt[] | null;
75
+ /** A path from a position (strokes before `stroke` are done, that stroke goes on from point / frac); null when nothing is left. */
76
+ export declare function pauseTrimPath(action: PauseRec, pos: PausePosition): PauseRec | null;
77
+ /**
78
+ * The helper's progress on a path that was itself cut at `base`, as a position in the ORIGINAL path. The cut path's
79
+ * point 0 is the re-pressed sample on base segment (point -> point + 1); its point k >= 1 is base point + k.
80
+ */
81
+ export declare function pauseAbsolute(base: PausePosition, rel: PauseProgress): PausePosition;
82
+ /** A resumeFrom as a position (step, stroke, point: whole numbers >= 0; frac in 0..1); null when absent. */
83
+ export declare function pausePositionOf(v: unknown): PausePosition | null;
84
+ /**
85
+ * A window / region path moved with its window (same size only: a canvas does not scale with its window, so a resized
86
+ * window stops the run). null when it cannot follow (screen pixels, or resized).
87
+ */
88
+ export declare function pauseShiftPath(action: PauseRec, from: PauseRect, to: PauseRect): PauseRec | null;
89
+ /**
90
+ * One step after its window moved from `from` to `to`: window / region paths move with it; points in screen pixels
91
+ * and refs bound to a screen taken before the move cannot follow (null: stop there); a label is found again (its
92
+ * snapshot binding is dropped). Steps on other windows, or without coordinates, are unchanged.
93
+ */
94
+ export declare function pauseShiftStep(step: PauseRec, from: PauseRect, to: PauseRect): PauseRec | null;
95
+ export type PauseStopReason = "stopped" | "max_pause" | "deadline" | "window_closed" | "dialog" | "moved" | "refocus" | "repress" | "dropped" | "unknown_progress" | "stale" | "too_many";
96
+ export interface PauseStop {
97
+ reason: PauseStopReason;
98
+ message: string;
99
+ /** Inside a path: where it stopped (stroke / point / frac in the step's original strokes). */
100
+ at?: PausePosition;
101
+ }
102
+ /** What the executor needs from its host. Every command goes through `exec` (the host's own checks apply). */
103
+ export interface PauseHost {
104
+ now(): number;
105
+ sleep(ms: number): Promise<void>;
106
+ /** Runs one helper command (never batch / handoff / keep_awake) and resolves with its result, cancelled or not. */
107
+ exec(action: PauseRec, id: string): Promise<PauseResult>;
108
+ /** Cancels one running command for a pause; its (stopped) result, with `progress`, still resolves `exec`. */
109
+ cancel(id: string): void;
110
+ /** Releases every key and mouse button the helper holds. */
111
+ release(): void;
112
+ /** The command was answered elsewhere (Stop, the deadline, the app closing): stop waiting, act on nothing. */
113
+ ended(): boolean;
114
+ /** Time left before the command's deadline (ms). */
115
+ timeLeft(): number;
116
+ /** Pushes the deadline back by up to `ms` from the pause budget (negative: gives time back); returns what moved. */
117
+ extend(ms: number): number;
118
+ /** Time of the last real user input the host saw (0: none). */
119
+ lastInputAt(): number;
120
+ /** The helper refused because someone is using the computer: start a new quiet period from now. */
121
+ markInput(): void;
122
+ /** Shows / hides "Paused while you use the computer". */
123
+ overlay(paused: boolean): void;
124
+ quietMs: number;
125
+ maxPauseMs: number;
126
+ }
127
+ /** State of one command that may pause (a single action, or every step of a batch). */
128
+ export declare class PauseRun {
129
+ count: number;
130
+ pausedMs: number;
131
+ refocused: number;
132
+ moved: boolean;
133
+ resumedStrokes: number;
134
+ /** Keys (hold_key) and buttons (mouse down) the run holds; released at a pause, pressed again before going on. */
135
+ readonly keys: Map<string, string>;
136
+ readonly buttons: Set<string>;
137
+ pointer: {
138
+ x: number;
139
+ y: number;
140
+ } | null;
141
+ /** The run's window (the last result's `current`). */
142
+ windowId: string | null;
143
+ /** Windows that moved during a pause: where they were first, where they are now. */
144
+ readonly shifts: Map<string, {
145
+ from: PauseRect;
146
+ to: PauseRect;
147
+ }>;
148
+ current: {
149
+ id: string;
150
+ kind: string;
151
+ } | null;
152
+ readonly cancelled: Set<string>;
153
+ /** The user took over since the last pause: the next step that sends input pauses first. */
154
+ interrupted: boolean;
155
+ holds(): boolean;
156
+ /** Real user input: cancel a pointer stream now, let go of what the run holds; the next step pauses. */
157
+ onUserInput(host: Pick<PauseHost, "cancel" | "release">): void;
158
+ /** What a finished step left held, where the pointer is, which window the run is in. */
159
+ track(action: PauseRec, r: PauseResult): void;
160
+ report(): PauseReport | undefined;
161
+ /** A step as it must run now: moved with its window if that window moved during an earlier pause (null: it cannot). */
162
+ adapt(step: PauseRec): PauseRec | null;
163
+ }
164
+ /** One line for a result that paused: how often, how long, what was done to go on. */
165
+ export declare function pauseReportText(r: PauseReport | undefined): string;
166
+ /** The message of a run that stopped: why, where, and how to continue (resume_from). */
167
+ export declare function pauseStopText(stop: PauseStop, where: string, resume: PausePosition): string;
168
+ /**
169
+ * One step that pauses for the user instead of failing. Before a step that sends input, it waits while the user is
170
+ * busy; a pointer stream the user interrupted goes on from where it was (a path from its last sample, a drag from
171
+ * its start if nothing was picked up, a move / hover / mouse step again). `start`: a path's resume position.
172
+ * Returns the step's result, or `stop` when the run cannot go on safely (the caller answers user_interrupted).
173
+ */
174
+ export declare function pauseRunStep(host: PauseHost, run: PauseRun, step: PauseRec, id: string, start?: PausePosition | null): Promise<{
175
+ result: PauseResult;
176
+ } | {
177
+ stop: PauseStop;
178
+ }>;
179
+ export {};
@@ -0,0 +1,488 @@
1
+ // Pause and resume for computer control (2026-10-23), runner copy: the CLI host (computerHostCli.ts, `npx
2
+ // @melaya/runner --computer`) pauses for the user exactly as the desktop app does. This package cannot import
3
+ // @melaya/desktop-contract, so the engine below is a byte-identical copy of
4
+ // packages/desktop-contract/src/computerPause.ts (server/test/computerPause.test.ts diffs the three copies).
5
+ // Edit the contract file first, then copy the block between the markers here and into the server.
6
+ export const PAUSE_DEFAULTS = {
7
+ /** The user must be idle this long before the run goes on. */
8
+ quietMs: 1500,
9
+ /** One pause lasts at most this long; then the run stops with user_interrupted and a resume point. */
10
+ maxPauseMs: 60_000,
11
+ /** Pause time the server grants per command on top of its deadline. */
12
+ budgetMs: 180_000,
13
+ /** A step interrupted more often than this stops (the user keeps taking over). */
14
+ maxResumesPerStep: 20,
15
+ /** Time kept back before the deadline: a pause ends in time to report where it stopped. */
16
+ reserveMs: 1500,
17
+ };
18
+ /** Reads that send no input: they run while the user is busy. Everything else waits until the user is idle. */
19
+ export const PAUSE_PASSIVE_KINDS = new Set([
20
+ "screen", "screenshot", "find", "get_text", "current_app", "list_windows", "list_apps", "list_displays", "clipboard_read",
21
+ "ocr", "wait", "zoom", "cursor_position", "read_table", "expect", "handoff", "keep_awake",
22
+ ]);
23
+ /** Pointer streams cancelled the moment the user takes over (never fight for the cursor); short atomic writes finish. */
24
+ export const PAUSE_CANCEL_KINDS = new Set(["path", "drag", "move", "hover", "mouse"]);
25
+ export function pauseNeedsIdle(kind) {
26
+ return !PAUSE_PASSIVE_KINDS.has(kind);
27
+ }
28
+ function pauseNum(v) {
29
+ const n = Number(v);
30
+ return Number.isFinite(n) ? n : 0;
31
+ }
32
+ function pauseIsRect(v) {
33
+ const r = v;
34
+ return !!r && typeof r === "object" && [r.x, r.y, r.width, r.height].every((n) => Number.isFinite(Number(n)));
35
+ }
36
+ export function pauseRectMoved(a, b, tol = 2) {
37
+ return Math.abs(a.x - b.x) > tol || Math.abs(a.y - b.y) > tol || Math.abs(a.width - b.width) > tol || Math.abs(a.height - b.height) > tol;
38
+ }
39
+ export function pauseRectResized(a, b, tol = 2) {
40
+ return Math.abs(a.width - b.width) > tol || Math.abs(a.height - b.height) > tol;
41
+ }
42
+ /** A position inside a stroke: frac 1 is the start of the next segment. */
43
+ function pauseNormalize(point, frac) {
44
+ let p = Math.max(0, Math.floor(pauseNum(point)));
45
+ let f = Math.min(1, Math.max(0, pauseNum(frac)));
46
+ if (f >= 1) {
47
+ p += 1;
48
+ f = 0;
49
+ }
50
+ return { point: p, frac: f };
51
+ }
52
+ /**
53
+ * The rest of one stroke from (point, frac): it starts on the last sample the pointer reached (pressed again there,
54
+ * one sample of overlap, no gap, nothing drawn twice beyond it), then every later point; times are shifted to start at
55
+ * 0. null when nothing is left (the stroke had reached its last point).
56
+ */
57
+ export function pauseTrimStroke(stroke, point, frac) {
58
+ const n = stroke.length;
59
+ const at = pauseNormalize(point, frac);
60
+ if (at.point === 0 && at.frac === 0)
61
+ return stroke.map((q) => q.slice());
62
+ if (at.point >= n - 1)
63
+ return null;
64
+ const a = stroke[at.point], b = stroke[at.point + 1];
65
+ const timed = a.length >= 3 && b.length >= 3;
66
+ const head = [a[0] + (b[0] - a[0]) * at.frac, a[1] + (b[1] - a[1]) * at.frac];
67
+ if (timed)
68
+ head.push(a[2] + (b[2] - a[2]) * at.frac);
69
+ const out = [head, ...stroke.slice(at.point + 1).map((q) => q.slice())];
70
+ if (timed) {
71
+ const t0 = head[2];
72
+ for (const q of out)
73
+ q[2] = Math.max(0, q[2] - t0);
74
+ }
75
+ return out;
76
+ }
77
+ /** A path from a position (strokes before `stroke` are done, that stroke goes on from point / frac); null when nothing is left. */
78
+ export function pauseTrimPath(action, pos) {
79
+ const strokes = Array.isArray(action.strokes) ? action.strokes : [];
80
+ const s = Math.max(0, Math.floor(pauseNum(pos.stroke)));
81
+ if (s >= strokes.length)
82
+ return null;
83
+ const first = pauseTrimStroke(strokes[s], pauseNum(pos.point), pauseNum(pos.frac));
84
+ const next = [...(first ? [first] : []), ...strokes.slice(s + 1).map((st) => st.map((q) => q.slice()))];
85
+ if (!next.length)
86
+ return null;
87
+ const out = { ...action, strokes: next };
88
+ delete out.resumeFrom;
89
+ return out;
90
+ }
91
+ /**
92
+ * The helper's progress on a path that was itself cut at `base`, as a position in the ORIGINAL path. The cut path's
93
+ * point 0 is the re-pressed sample on base segment (point -> point + 1); its point k >= 1 is base point + k.
94
+ */
95
+ export function pauseAbsolute(base, rel) {
96
+ const bs = Math.max(0, Math.floor(pauseNum(base.stroke)));
97
+ const b = pauseNormalize(base.point, base.frac);
98
+ const rs = Math.max(0, Math.floor(pauseNum(rel.stroke)));
99
+ if (!rel.pressed)
100
+ return rs === 0 ? { stroke: bs, point: b.point, frac: b.frac } : { stroke: bs + rs, point: 0, frac: 0 };
101
+ const r = pauseNormalize(rel.point, rel.frac);
102
+ if (rs > 0)
103
+ return { stroke: bs + rs, point: r.point, frac: r.frac };
104
+ if (r.point === 0)
105
+ return { stroke: bs, point: b.point, frac: b.frac + r.frac * (1 - b.frac) };
106
+ return { stroke: bs, point: b.point + r.point, frac: r.frac };
107
+ }
108
+ /** A resumeFrom as a position (step, stroke, point: whole numbers >= 0; frac in 0..1); null when absent. */
109
+ export function pausePositionOf(v) {
110
+ if (!v || typeof v !== "object")
111
+ return null;
112
+ const o = v;
113
+ const out = {};
114
+ for (const k of ["step", "stroke", "point"])
115
+ if (o[k] !== undefined)
116
+ out[k] = Math.max(0, Math.floor(pauseNum(o[k])));
117
+ if (o.frac !== undefined)
118
+ out.frac = Math.min(1, Math.max(0, pauseNum(o.frac)));
119
+ return out;
120
+ }
121
+ /**
122
+ * A window / region path moved with its window (same size only: a canvas does not scale with its window, so a resized
123
+ * window stops the run). null when it cannot follow (screen pixels, or resized).
124
+ */
125
+ export function pauseShiftPath(action, from, to) {
126
+ if (!pauseRectMoved(from, to))
127
+ return action;
128
+ if (pauseRectResized(from, to))
129
+ return null;
130
+ const anchor = action.anchor === "window" || action.anchor === "region" ? action.anchor : pauseIsRect(action.windowRect) ? "window" : "screen";
131
+ if (anchor === "screen")
132
+ return null;
133
+ const dx = to.x - from.x, dy = to.y - from.y;
134
+ const strokes = (Array.isArray(action.strokes) ? action.strokes : []).map((st) => st.map((q) => {
135
+ const c = q.slice();
136
+ c[0] = c[0] + dx;
137
+ c[1] = c[1] + dy;
138
+ return c;
139
+ }));
140
+ const out = { ...action, strokes };
141
+ if (pauseIsRect(action.windowRect)) {
142
+ const w = action.windowRect;
143
+ out.windowRect = { x: w.x + dx, y: w.y + dy, width: w.width, height: w.height };
144
+ }
145
+ return out;
146
+ }
147
+ /**
148
+ * One step after its window moved from `from` to `to`: window / region paths move with it; points in screen pixels
149
+ * and refs bound to a screen taken before the move cannot follow (null: stop there); a label is found again (its
150
+ * snapshot binding is dropped). Steps on other windows, or without coordinates, are unchanged.
151
+ */
152
+ export function pauseShiftStep(step, from, to) {
153
+ if (!pauseRectMoved(from, to))
154
+ return step;
155
+ const kind = String(step.kind);
156
+ if (kind === "path")
157
+ return pauseShiftPath(step, pauseIsRect(step.windowRect) ? step.windowRect : from, to);
158
+ if (kind === "move")
159
+ return null;
160
+ for (const k of ["target", "from", "to"]) {
161
+ const t = step[k];
162
+ if (t && typeof t === "object" && t.x !== undefined && t.y !== undefined)
163
+ return null;
164
+ }
165
+ if (step.snapshotId === undefined)
166
+ return step;
167
+ for (const k of ["target", "from", "to"]) {
168
+ const t = step[k];
169
+ if (t && typeof t === "object" && typeof t.ref === "string")
170
+ return null;
171
+ }
172
+ const { snapshotId: _snap, ...rest } = step;
173
+ return rest;
174
+ }
175
+ /** State of one command that may pause (a single action, or every step of a batch). */
176
+ export class PauseRun {
177
+ count = 0;
178
+ pausedMs = 0;
179
+ refocused = 0;
180
+ moved = false;
181
+ resumedStrokes = 0;
182
+ /** Keys (hold_key) and buttons (mouse down) the run holds; released at a pause, pressed again before going on. */
183
+ keys = new Map();
184
+ buttons = new Set();
185
+ pointer = null;
186
+ /** The run's window (the last result's `current`). */
187
+ windowId = null;
188
+ /** Windows that moved during a pause: where they were first, where they are now. */
189
+ shifts = new Map();
190
+ current = null;
191
+ cancelled = new Set();
192
+ /** The user took over since the last pause: the next step that sends input pauses first. */
193
+ interrupted = false;
194
+ holds() {
195
+ return this.keys.size > 0 || this.buttons.size > 0;
196
+ }
197
+ /** Real user input: cancel a pointer stream now, let go of what the run holds; the next step pauses. */
198
+ onUserInput(host) {
199
+ this.interrupted = true;
200
+ const cur = this.current;
201
+ if (cur && PAUSE_CANCEL_KINDS.has(cur.kind) && !this.cancelled.has(cur.id)) {
202
+ this.cancelled.add(cur.id);
203
+ host.cancel(cur.id);
204
+ }
205
+ if (this.holds())
206
+ host.release();
207
+ }
208
+ /** What a finished step left held, where the pointer is, which window the run is in. */
209
+ track(action, r) {
210
+ const kind = String(action.kind);
211
+ if (kind === "hold_key") {
212
+ const k = String(action.key || "").trim();
213
+ if (action.state === "down")
214
+ this.keys.set(k.toLowerCase(), k);
215
+ else
216
+ this.keys.delete(k.toLowerCase());
217
+ }
218
+ else if (kind === "mouse") {
219
+ const b = typeof action.button === "string" ? action.button : "left";
220
+ if (action.state === "down")
221
+ this.buttons.add(b);
222
+ else
223
+ this.buttons.delete(b);
224
+ }
225
+ const p = r.pointer;
226
+ const t = (action.target ?? action.to);
227
+ if (p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y)))
228
+ this.pointer = { x: Number(p.x), y: Number(p.y) };
229
+ else if (kind === "move" && Number.isFinite(Number(action.x)) && Number.isFinite(Number(action.y)))
230
+ this.pointer = { x: Number(action.x), y: Number(action.y) };
231
+ else if (t && typeof t === "object" && Number.isFinite(Number(t.x)) && Number.isFinite(Number(t.y)))
232
+ this.pointer = { x: Number(t.x), y: Number(t.y) };
233
+ const cur = r.current;
234
+ if (cur && typeof cur.windowId === "string" && cur.windowId)
235
+ this.windowId = cur.windowId;
236
+ else if (typeof action.windowId === "string" && action.windowId)
237
+ this.windowId = action.windowId;
238
+ }
239
+ report() {
240
+ if (!this.count)
241
+ return undefined;
242
+ return {
243
+ count: this.count, pausedMs: Math.round(this.pausedMs),
244
+ ...(this.refocused ? { refocused: this.refocused } : {}),
245
+ ...(this.moved ? { moved: true } : {}),
246
+ ...(this.resumedStrokes ? { resumedStrokes: this.resumedStrokes } : {}),
247
+ };
248
+ }
249
+ /** A step as it must run now: moved with its window if that window moved during an earlier pause (null: it cannot). */
250
+ adapt(step) {
251
+ if (!this.shifts.size)
252
+ return step;
253
+ const wid = typeof step.windowId === "string" ? step.windowId : this.windowId;
254
+ const m = wid ? this.shifts.get(wid) : undefined;
255
+ return m ? pauseShiftStep(step, m.from, m.to) : step;
256
+ }
257
+ }
258
+ function pauseSecs(ms) {
259
+ return ms >= 10_000 ? `${Math.round(ms / 1000)} s` : `${(ms / 1000).toFixed(1)} s`;
260
+ }
261
+ /** One line for a result that paused: how often, how long, what was done to go on. */
262
+ export function pauseReportText(r) {
263
+ if (!r || !r.count)
264
+ return "";
265
+ const extra = [
266
+ r.refocused ? "brought the window back to the front" : "",
267
+ r.moved ? "followed the window to where it was moved" : "",
268
+ r.resumedStrokes ? `continued ${r.resumedStrokes} stroke${r.resumedStrokes === 1 ? "" : "s"} midway` : "",
269
+ ].filter(Boolean).join(", ");
270
+ return `Paused ${r.count === 1 ? "once" : `${r.count} times`} (${pauseSecs(r.pausedMs)}) while the user used the computer, then resumed${extra ? ` (${extra})` : ""}.`;
271
+ }
272
+ /** The message of a run that stopped: why, where, and how to continue (resume_from). */
273
+ export function pauseStopText(stop, where, resume) {
274
+ return `${stop.message} Melaya stopped ${where}; nothing after that point was done. Read the screen first, then call again with the same ` +
275
+ `steps / strokes and resume_from ${JSON.stringify(resume)} to continue exactly there without redoing anything, or ask the user.`;
276
+ }
277
+ function pauseStop(reason, message, at) {
278
+ return { stop: { reason, message, ...(at ? { at } : {}) } };
279
+ }
280
+ function pauseWindows(r) {
281
+ if (!r.ok || !Array.isArray(r.windows))
282
+ return null;
283
+ return r.windows.filter((w) => w && typeof w.windowId === "string" && pauseIsRect(w.bounds));
284
+ }
285
+ /**
286
+ * The pause itself: let go of what the run holds, wait until the user has been idle `quietMs` (at most `maxPauseMs`,
287
+ * within the deadline), then check the world before going on: the window still exists, no new window of its app
288
+ * (a dialog) took the front, it is in front again (re-focused only now, when the user is idle), where it moved;
289
+ * finally press again what the run held. Returns the window move, if any.
290
+ */
291
+ async function pauseHold(host, run, step, id) {
292
+ const t0 = host.now();
293
+ run.count++;
294
+ const n = run.count;
295
+ if (run.holds())
296
+ host.release();
297
+ host.overlay(true);
298
+ const granted = host.extend(host.maxPauseMs);
299
+ try {
300
+ const before = pauseWindows(await host.exec({ kind: "list_windows" }, `${id}~p${n}a`));
301
+ const wid = typeof step.windowId === "string" ? step.windowId : run.windowId;
302
+ const target = wid && (!before || before.some((w) => w.windowId === wid)) ? wid
303
+ : (before?.find((w) => w.focused && !w.minimized)?.windowId ?? wid ?? null);
304
+ for (;;) {
305
+ if (host.ended())
306
+ return pauseStop("stopped", "Stopped while paused.");
307
+ const now = host.now();
308
+ const quiet = now - host.lastInputAt();
309
+ if (quiet >= host.quietMs)
310
+ break;
311
+ if (now - t0 >= host.maxPauseMs)
312
+ return pauseStop("max_pause", `The user kept using the computer for more than ${Math.round(host.maxPauseMs / 1000)} s, so Melaya stopped instead of waiting longer.`);
313
+ if (host.timeLeft() <= PAUSE_DEFAULTS.reserveMs)
314
+ return pauseStop("deadline", "The time for this call ran out while Melaya waited for the user to finish.");
315
+ await host.sleep(Math.max(10, Math.min(100, host.quietMs - quiet + 5)));
316
+ }
317
+ run.interrupted = false;
318
+ if (host.ended())
319
+ return pauseStop("stopped", "Stopped while paused.");
320
+ let shift;
321
+ if (target) {
322
+ let after = pauseWindows(await host.exec({ kind: "list_windows" }, `${id}~p${n}b`));
323
+ let a = after?.find((w) => w.windowId === target);
324
+ if (after && !a)
325
+ return pauseStop("window_closed", "The window Melaya was working in was closed while it waited for the user.");
326
+ if (after && a) {
327
+ const f = after.find((w) => w.focused && !w.minimized);
328
+ if (f && f.windowId !== target && before && !before.some((w) => w.windowId === f.windowId) && (!f.appId || f.appId === a.appId)) {
329
+ return pauseStop("dialog", `A new window of the app ("${String(f.title || "").slice(0, 60)}", a dialog?) opened while Melaya waited, so Melaya did not act on it.`);
330
+ }
331
+ if (a.minimized || !a.focused) {
332
+ const fr = await host.exec({ kind: "focus_window", windowId: target }, `${id}~p${n}f`);
333
+ if (!fr.ok || host.ended())
334
+ return pauseStop("refocus", `The window could not be brought back to the front after the user switched away (${String(fr.error?.message || "focus refused").slice(0, 160)}).`);
335
+ run.refocused++;
336
+ if (a.minimized) {
337
+ after = pauseWindows(await host.exec({ kind: "list_windows" }, `${id}~p${n}c`));
338
+ a = after?.find((w) => w.windowId === target) ?? a;
339
+ }
340
+ }
341
+ const b = before?.find((w) => w.windowId === target);
342
+ if (b && !b.minimized && !a.minimized && pauseRectMoved(b.bounds, a.bounds)) {
343
+ shift = { windowId: target, from: b.bounds, to: a.bounds };
344
+ const prev = run.shifts.get(target);
345
+ run.shifts.set(target, { from: prev ? prev.from : b.bounds, to: a.bounds });
346
+ run.moved = true;
347
+ }
348
+ }
349
+ }
350
+ // What the run held before the pause is pressed again; the pointer goes back by our own move, never assumed.
351
+ for (const key of run.keys.values()) {
352
+ const r = await host.exec({ kind: "hold_key", key, state: "down", observe: "none" }, `${id}~p${n}k`);
353
+ if (!r.ok || host.ended())
354
+ return pauseStop("repress", `Melaya could not press ${key} again after the pause.`);
355
+ }
356
+ for (const button of run.buttons) {
357
+ const at = run.pointer;
358
+ if (!at)
359
+ return pauseStop("repress", `Melaya let go of the ${button} mouse button for the user and does not know where to press it again.`);
360
+ let x = at.x, y = at.y;
361
+ if (shift) {
362
+ x += shift.to.x - shift.from.x;
363
+ y += shift.to.y - shift.from.y;
364
+ }
365
+ const r = await host.exec({ kind: "mouse", target: { x, y }, button, state: "down", observe: "none" }, `${id}~p${n}m`);
366
+ if (!r.ok || host.ended())
367
+ return pauseStop("repress", `Melaya could not press the ${button} mouse button again after the pause (${String(r.error?.message || "").slice(0, 160)}).`);
368
+ }
369
+ return shift ? { shift } : {};
370
+ }
371
+ finally {
372
+ const used = host.now() - t0;
373
+ run.pausedMs += used;
374
+ if (granted > used)
375
+ host.extend(-(granted - used));
376
+ if (!host.ended())
377
+ host.overlay(false);
378
+ }
379
+ }
380
+ /**
381
+ * One step that pauses for the user instead of failing. Before a step that sends input, it waits while the user is
382
+ * busy; a pointer stream the user interrupted goes on from where it was (a path from its last sample, a drag from
383
+ * its start if nothing was picked up, a move / hover / mouse step again). `start`: a path's resume position.
384
+ * Returns the step's result, or `stop` when the run cannot go on safely (the caller answers user_interrupted).
385
+ */
386
+ export async function pauseRunStep(host, run, step, id, start) {
387
+ const kind = String(step.kind);
388
+ const isPath = kind === "path";
389
+ const adapted = run.adapt(step);
390
+ if (!adapted)
391
+ return pauseStop("moved", "The window moved while Melaya waited for the user, and this step uses screen coordinates (or a ref) of where it was.");
392
+ let orig = adapted;
393
+ let pos = isPath && start ? { stroke: start.stroke ?? 0, point: start.point ?? 0, frac: start.frac ?? 0 } : { stroke: 0, point: 0, frac: 0 };
394
+ const done = () => ({ ok: true, kind, text: "Nothing was left to draw: the path had reached its end.", elapsedMs: 0 });
395
+ const first = isPath ? pauseTrimPath(orig, pos) : orig;
396
+ if (!first)
397
+ return { result: done() };
398
+ let attempt = first;
399
+ let tries = 0;
400
+ let afterPause = false;
401
+ for (;;) {
402
+ if (host.ended())
403
+ return { result: { ok: false, kind, error: { code: "stopped", message: "Stopped." }, elapsedMs: 0 } };
404
+ if (pauseNeedsIdle(kind) && (run.interrupted || host.now() - host.lastInputAt() < host.quietMs)) {
405
+ const h = await pauseHold(host, run, orig, id);
406
+ if ("stop" in h)
407
+ return { stop: { ...h.stop, ...(isPath ? { at: pos } : {}) } };
408
+ afterPause = true;
409
+ if (h.shift) {
410
+ const moved = pauseShiftStep(orig, isPath && pauseIsRect(orig.windowRect) ? orig.windowRect : h.shift.from, h.shift.to);
411
+ if (!moved) {
412
+ return pauseStop("moved", pauseRectResized(h.shift.from, h.shift.to)
413
+ ? "The window was resized while Melaya waited for the user, so the coordinates planned for its old size would land elsewhere."
414
+ : "The window moved while Melaya waited for the user, and this step uses screen coordinates (or a ref) of where it was.", isPath ? pos : undefined);
415
+ }
416
+ orig = moved;
417
+ }
418
+ const again = isPath ? pauseTrimPath(orig, pos) : orig;
419
+ if (!again)
420
+ return { result: done() };
421
+ attempt = again;
422
+ // The user may have come back while the window was checked: look again before sending anything.
423
+ continue;
424
+ }
425
+ const hid = tries === 0 ? id : `${id}~r${tries}`;
426
+ run.current = { id: hid, kind: String(attempt.kind) };
427
+ const r = await host.exec(attempt, hid);
428
+ run.current = null;
429
+ const cancelled = run.cancelled.delete(hid);
430
+ if (host.ended())
431
+ return { result: r };
432
+ const code = r.ok ? "" : String(r.error?.code ?? "");
433
+ // The helper's own check (Linux: real input within 1.5 s) refused before sending anything.
434
+ const refused = code === "user_interrupted";
435
+ if (r.ok || (!cancelled && !refused)) {
436
+ if (!r.ok && afterPause && (code === "frame_stale" || code === "stale_ref")) {
437
+ const t = (attempt.target ?? attempt.from);
438
+ if (attempt.snapshotId !== undefined && t && typeof t === "object" && typeof t.label === "string" && tries < PAUSE_DEFAULTS.maxResumesPerStep) {
439
+ const relabel = { ...attempt };
440
+ delete relabel.snapshotId;
441
+ attempt = relabel;
442
+ if (!isPath)
443
+ orig = relabel;
444
+ tries++;
445
+ continue;
446
+ }
447
+ return pauseStop("stale", "The window changed while Melaya waited for the user, and this step targets an element of the screen read before the pause.", isPath ? pos : undefined);
448
+ }
449
+ if (r.ok)
450
+ run.track(attempt, r);
451
+ return { result: r };
452
+ }
453
+ if (refused)
454
+ host.markInput();
455
+ run.interrupted = true;
456
+ tries++;
457
+ if (tries > PAUSE_DEFAULTS.maxResumesPerStep)
458
+ return pauseStop("too_many", "The user took over this step too many times in a row.", isPath ? pos : undefined);
459
+ if (refused)
460
+ continue; // nothing was sent: the same attempt runs after the pause
461
+ if (isPath) {
462
+ const prog = r.progress;
463
+ if (!prog || typeof prog !== "object")
464
+ return pauseStop("unknown_progress", "A stroke was interrupted and this computer's helper cannot tell how far it got (it may be partly drawn).", pos);
465
+ if (orig.gesture === "drag") {
466
+ if (prog.pressed || pauseNum(prog.stroke) > 0)
467
+ return pauseStop("dropped", "The drag was interrupted after it had picked the item up; letting go for the user dropped it where the pointer was.", { stroke: 0, point: 0, frac: 0 });
468
+ continue; // nothing picked up yet: the whole gesture again
469
+ }
470
+ pos = pauseAbsolute(pos, prog);
471
+ const rest = pauseTrimPath(orig, pos);
472
+ if (!rest)
473
+ return { result: done() };
474
+ attempt = rest;
475
+ if (prog.pressed)
476
+ run.resumedStrokes++;
477
+ continue;
478
+ }
479
+ if (kind === "drag") {
480
+ const prog = r.progress;
481
+ if (!prog || prog.pressed)
482
+ return pauseStop("dropped", "The drag was interrupted after it had picked the item up; letting go for the user dropped it where the pointer was.");
483
+ continue;
484
+ }
485
+ // move, hover, mouse: sending them again is the same input (the button the run held is pressed again first).
486
+ }
487
+ }
488
+ // END COMPUTER PAUSE ENGINE