@melaya/runner 1.1.74 → 1.1.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/computerHelperManifest.d.ts +1 -1
- package/dist/computerHelperManifest.js +5 -5
- package/dist/computerHostCli.d.ts +13 -2
- package/dist/computerHostCli.js +200 -49
- package/dist/computerPause.d.ts +179 -0
- package/dist/computerPause.js +488 -0
- package/dist/computerRelay.js +8 -1
- package/dist/hostChannel.d.ts +1 -0
- package/package.json +50 -50
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
// Pinned computer helper releases for the runner CLI host (computerHostCli.ts),
|
|
6
6
|
// keyed by `${process.platform}-${process.arch}`. A download is only accepted
|
|
7
7
|
// when its sha256 matches.
|
|
8
|
-
export const COMPUTER_HELPER_VERSION = "1.2.
|
|
8
|
+
export const COMPUTER_HELPER_VERSION = "1.2.1";
|
|
9
9
|
export const COMPUTER_HELPER_MANIFEST = {
|
|
10
10
|
"linux-arm64": {
|
|
11
|
-
"url": "https://api.melaya.org/desktop/helpers/1.2.
|
|
12
|
-
"sha256": "
|
|
11
|
+
"url": "https://api.melaya.org/desktop/helpers/1.2.1/melaya-computer-linux-aarch64",
|
|
12
|
+
"sha256": "d4d47603d30e2b4f136a68b88654e331d2ea809520c8e6d1c9f8a604aa709207"
|
|
13
13
|
},
|
|
14
14
|
"linux-x64": {
|
|
15
|
-
"url": "https://api.melaya.org/desktop/helpers/1.2.
|
|
16
|
-
"sha256": "
|
|
15
|
+
"url": "https://api.melaya.org/desktop/helpers/1.2.1/melaya-computer-linux-x86_64",
|
|
16
|
+
"sha256": "c2a27d0f3d62efc26486f5cbb56876dd036a9d059a39d251a2704a62af6aacfb"
|
|
17
17
|
}
|
|
18
18
|
};
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* Like the desktop app, this host runs `batch` (step by step, per-step
|
|
20
20
|
* `expect`), `keep_awake` (systemd-inhibit / caffeinate) and `handoff` (no one
|
|
21
21
|
* is at a headless screen: unsupported) itself, and never sends them to the
|
|
22
|
-
* helper.
|
|
22
|
+
* helper. Real user input (a VNC / console user) PAUSES a run instead of
|
|
23
|
+
* stopping it (2026-10-23, computerPause.ts, the same engine as the desktop
|
|
24
|
+
* app): held input is let go, a pointer stream is cancelled, the run waits
|
|
25
|
+
* until the user is idle, checks the window and continues where it was.
|
|
23
26
|
*
|
|
24
27
|
* computerRelay.ts (server socket <-> executor) loads this module lazily and
|
|
25
28
|
* calls createCliComputerHost({ send, verbose }) when the runner is not inside
|
|
@@ -40,6 +43,8 @@
|
|
|
40
43
|
* packages/desktop-contract/src/computer.ts (this package cannot import it).
|
|
41
44
|
*/
|
|
42
45
|
import { type ChildProcess } from "child_process";
|
|
46
|
+
/** COMPUTER_POINTER_KINDS: in a batch, these steps skip their own after-step observation except the last one. */
|
|
47
|
+
export declare const QUIET_POINTER_KINDS: ReadonlySet<string>;
|
|
43
48
|
export type ComputerAppTier = "view" | "click" | "full";
|
|
44
49
|
export interface AllowedApp {
|
|
45
50
|
appId: string;
|
|
@@ -52,6 +57,7 @@ export type ComputerRunnerToHost = {
|
|
|
52
57
|
deadlineMs: number;
|
|
53
58
|
allowedAppIds: string[];
|
|
54
59
|
allowedApps?: AllowedApp[];
|
|
60
|
+
pauseBudgetMs?: number;
|
|
55
61
|
} | {
|
|
56
62
|
type: "computer.stop";
|
|
57
63
|
reason: string;
|
|
@@ -121,7 +127,7 @@ export declare function checkCommand(msg: {
|
|
|
121
127
|
* accepted when its sha256 matches. Empty until the first release: use
|
|
122
128
|
* MELAYA_COMPUTER_HELPER or --computer-helper meanwhile.
|
|
123
129
|
*/
|
|
124
|
-
export declare const HELPER_VERSION = "1.2.
|
|
130
|
+
export declare const HELPER_VERSION = "1.2.1";
|
|
125
131
|
export declare const HELPER_MANIFEST: Record<string, {
|
|
126
132
|
url: string;
|
|
127
133
|
sha256: string;
|
|
@@ -187,6 +193,11 @@ export declare class HelperProcess {
|
|
|
187
193
|
*/
|
|
188
194
|
/** Releases every key / button the helper holds (helper 1.2.0+; older helpers ignore the op). */
|
|
189
195
|
release(): void;
|
|
196
|
+
/**
|
|
197
|
+
* Cancels one command for a pause (pause and resume): unlike cancel(), its `stopped` reply (with the path's
|
|
198
|
+
* `progress`) still answers the request, so the host knows how far it got and continues from there.
|
|
199
|
+
*/
|
|
200
|
+
pauseCancel(target: string): void;
|
|
190
201
|
cancel(target?: string): void;
|
|
191
202
|
get running(): boolean;
|
|
192
203
|
dispose(): void;
|
package/dist/computerHostCli.js
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* Like the desktop app, this host runs `batch` (step by step, per-step
|
|
20
20
|
* `expect`), `keep_awake` (systemd-inhibit / caffeinate) and `handoff` (no one
|
|
21
21
|
* is at a headless screen: unsupported) itself, and never sends them to the
|
|
22
|
-
* helper.
|
|
22
|
+
* helper. Real user input (a VNC / console user) PAUSES a run instead of
|
|
23
|
+
* stopping it (2026-10-23, computerPause.ts, the same engine as the desktop
|
|
24
|
+
* app): held input is let go, a pointer stream is cancelled, the run waits
|
|
25
|
+
* until the user is idle, checks the window and continues where it was.
|
|
23
26
|
*
|
|
24
27
|
* computerRelay.ts (server socket <-> executor) loads this module lazily and
|
|
25
28
|
* calls createCliComputerHost({ send, verbose }) when the runner is not inside
|
|
@@ -49,6 +52,10 @@ import { homedir } from "os";
|
|
|
49
52
|
import { delimiter, dirname, join } from "path";
|
|
50
53
|
import { fileURLToPath } from "url";
|
|
51
54
|
import { COMPUTER_HELPER_MANIFEST, COMPUTER_HELPER_VERSION } from "./computerHelperManifest.js";
|
|
55
|
+
import { PAUSE_DEFAULTS, PauseRun, pauseNeedsIdle, pausePositionOf, pauseReportText, pauseRunStep, pauseStopText, } from "./computerPause.js";
|
|
56
|
+
// ── contract mirrors ────────────────────────────────────────────────────────
|
|
57
|
+
/** COMPUTER_POINTER_KINDS: in a batch, these steps skip their own after-step observation except the last one. */
|
|
58
|
+
export const QUIET_POINTER_KINDS = new Set(["move", "mouse", "path", "hover", "drag", "click", "scroll", "cursor_position", "wait", "hold_key"]);
|
|
52
59
|
export const COMPUTER_ACTION_KINDS = [
|
|
53
60
|
"screen", "screenshot", "find", "get_text", "current_app", "list_windows", "list_apps", "list_displays",
|
|
54
61
|
"clipboard_read", "ocr", "wait", "hover", "move", "scroll",
|
|
@@ -472,6 +479,13 @@ export class HelperProcess {
|
|
|
472
479
|
release() {
|
|
473
480
|
this.send({ op: "release" });
|
|
474
481
|
}
|
|
482
|
+
/**
|
|
483
|
+
* Cancels one command for a pause (pause and resume): unlike cancel(), its `stopped` reply (with the path's
|
|
484
|
+
* `progress`) still answers the request, so the host knows how far it got and continues from there.
|
|
485
|
+
*/
|
|
486
|
+
pauseCancel(target) {
|
|
487
|
+
this.send({ op: "cancel", target });
|
|
488
|
+
}
|
|
475
489
|
cancel(target) {
|
|
476
490
|
this.send(target ? { op: "cancel", target } : { op: "cancel" });
|
|
477
491
|
if (!target || !this.child)
|
|
@@ -587,8 +601,30 @@ export function _resetCliComputerHost() {
|
|
|
587
601
|
cliEnabled = false;
|
|
588
602
|
cliDefaults = {};
|
|
589
603
|
}
|
|
590
|
-
/**
|
|
591
|
-
const
|
|
604
|
+
/** Most pause time (someone at the keyboard) one command may add to its deadline (contract COMPUTER_PAUSE.budgetMs, with room). */
|
|
605
|
+
const MAX_PAUSE_BUDGET_MS = 300_000;
|
|
606
|
+
/** A run that paused and could not go on: user_interrupted with where it stopped (a Stop keeps "stopped"). */
|
|
607
|
+
function pauseStopResult(kind, stop, message, elapsedMs, steps, resumeFrom, pauses) {
|
|
608
|
+
return {
|
|
609
|
+
...errorResult(kind, stop.reason === "stopped" ? "stopped" : "user_interrupted", message, elapsedMs),
|
|
610
|
+
...(steps ? { steps } : {}),
|
|
611
|
+
...(resumeFrom && stop.reason !== "stopped" ? { resumeFrom } : {}),
|
|
612
|
+
...(pauses ? { pauses } : {}),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
/** A resume position the agent copies back (stroke / point / frac, rounded). */
|
|
616
|
+
function cleanPosition(p) {
|
|
617
|
+
return { stroke: Math.max(0, Math.floor(p.stroke ?? 0)), point: Math.max(0, Math.floor(p.point ?? 0)), frac: Math.round(Math.min(1, Math.max(0, p.frac ?? 0)) * 1000) / 1000 };
|
|
618
|
+
}
|
|
619
|
+
/** A finished command that paused for the user: status completed_with_pauses and the compact report. */
|
|
620
|
+
function withPauses(r, pauses) {
|
|
621
|
+
if (!pauses)
|
|
622
|
+
return r;
|
|
623
|
+
if (!r.ok)
|
|
624
|
+
return { ...r, pauses };
|
|
625
|
+
const text = typeof r.text === "string" ? r.text : "";
|
|
626
|
+
return { ...r, text: `${text}${text ? "\n" : ""}${pauseReportText(pauses)}`, status: "completed_with_pauses", pauses };
|
|
627
|
+
}
|
|
592
628
|
export function createCliComputerHost(options = {}) {
|
|
593
629
|
const opts = { ...cliDefaults, ...options };
|
|
594
630
|
const env = opts.env ?? process.env;
|
|
@@ -604,6 +640,10 @@ export function createCliComputerHost(options = {}) {
|
|
|
604
640
|
let capsAt = 0;
|
|
605
641
|
let disposed = false;
|
|
606
642
|
let lastUserInput = 0;
|
|
643
|
+
/** Bumped by stopAll: a run started before it has ended (pause and resume stop waiting). */
|
|
644
|
+
let stopGen = 0;
|
|
645
|
+
/** The run in flight that pauses for the user (pause and resume, computerPause.ts). */
|
|
646
|
+
let active = null;
|
|
607
647
|
const keepAwake = opts.keepAwake ?? new KeepAwake({ spawn: opts.spawn, env });
|
|
608
648
|
const ensureHelper = async () => {
|
|
609
649
|
if (disposed)
|
|
@@ -624,7 +664,12 @@ export function createCliComputerHost(options = {}) {
|
|
|
624
664
|
onEvent: (e) => {
|
|
625
665
|
if (e.event === "user_input") {
|
|
626
666
|
lastUserInput = Date.now();
|
|
627
|
-
|
|
667
|
+
// Pause and resume: let go of what the run holds and cancel a pointer stream now; it goes on once they stop.
|
|
668
|
+
if (active) {
|
|
669
|
+
if (!active.run.interrupted)
|
|
670
|
+
log(" [computer] someone is using this computer: the run pauses until they stop");
|
|
671
|
+
active.run.onUserInput(active.host);
|
|
672
|
+
}
|
|
628
673
|
}
|
|
629
674
|
else if (e.event === "log") {
|
|
630
675
|
log(` [computer] ${String(e.message || "").slice(0, 300)}`);
|
|
@@ -654,7 +699,8 @@ export function createCliComputerHost(options = {}) {
|
|
|
654
699
|
kinds.add("keep_awake");
|
|
655
700
|
else
|
|
656
701
|
kinds.delete("keep_awake");
|
|
657
|
-
|
|
702
|
+
// pauseResume: this host pauses for the user and resumes (computerPause.ts), takes pauseBudgetMs and resumeFrom.
|
|
703
|
+
caps = { ...c, kinds: COMPUTER_ACTION_KINDS.filter((k) => kinds.has(k)), host: "cli", pauseResume: true };
|
|
658
704
|
}
|
|
659
705
|
else {
|
|
660
706
|
caps = null;
|
|
@@ -697,61 +743,160 @@ export function createCliComputerHost(options = {}) {
|
|
|
697
743
|
return errorResult(kind, "helper_unavailable", `The computer helper stopped: ${m}`, Date.now() - started);
|
|
698
744
|
}
|
|
699
745
|
};
|
|
700
|
-
/**
|
|
701
|
-
const
|
|
746
|
+
/** What the pause engine needs from this host for one command (every command still goes through runOnHelper). */
|
|
747
|
+
const pauseHost = (allowedAppIds, allowedApps, clock) => {
|
|
748
|
+
const gen = stopGen;
|
|
749
|
+
return {
|
|
750
|
+
now: () => Date.now(),
|
|
751
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
752
|
+
exec: (a, hid) => runOnHelper(hid, a, allowedAppIds, allowedApps, Math.max(500, clock.at - Date.now())),
|
|
753
|
+
cancel: (hid) => { try {
|
|
754
|
+
helper?.pauseCancel(hid);
|
|
755
|
+
}
|
|
756
|
+
catch { /* helper gone */ } },
|
|
757
|
+
release: () => { try {
|
|
758
|
+
helper?.release();
|
|
759
|
+
}
|
|
760
|
+
catch { /* best effort */ } },
|
|
761
|
+
ended: () => disposed || gen !== stopGen,
|
|
762
|
+
timeLeft: () => clock.at - Date.now(),
|
|
763
|
+
extend: (ms) => {
|
|
764
|
+
const d = ms > 0 ? Math.min(ms, clock.budgetLeft) : -Math.min(-ms, clock.used);
|
|
765
|
+
clock.budgetLeft -= d;
|
|
766
|
+
clock.used += d;
|
|
767
|
+
clock.at += d;
|
|
768
|
+
return d;
|
|
769
|
+
},
|
|
770
|
+
lastInputAt: () => lastUserInput,
|
|
771
|
+
markInput: () => { lastUserInput = Date.now(); },
|
|
772
|
+
overlay: (on) => log(on ? " [computer] paused: someone is using this computer; it resumes when they stop" : " [computer] resumed"),
|
|
773
|
+
quietMs: PAUSE_DEFAULTS.quietMs,
|
|
774
|
+
maxPauseMs: PAUSE_DEFAULTS.maxPauseMs,
|
|
775
|
+
};
|
|
776
|
+
};
|
|
777
|
+
/** batch, run here step by step (PROTOCOL.md §7): per-step expect, onError, pause and resume, shared deadline. */
|
|
778
|
+
const runBatch = async (id, action, allowedAppIds, allowedApps, deadlineMs, pauseBudgetMs = 0) => {
|
|
702
779
|
const started = Date.now();
|
|
703
780
|
const steps = action.steps || [];
|
|
704
781
|
const stopDefault = action.stopOnError !== false;
|
|
782
|
+
// resumeFrom (2026-10-23): continue a batch that stopped midway; a path at that step goes on at its stroke.
|
|
783
|
+
const resume = pausePositionOf(action.resumeFrom);
|
|
784
|
+
const start = resume?.step ?? 0;
|
|
785
|
+
if (start >= steps.length)
|
|
786
|
+
return errorResult("batch", "invalid_action", `resumeFrom.step ${start} is past the last step (the batch has ${steps.length}; steps count from 0).`);
|
|
787
|
+
const clock = { at: started + deadlineMs, budgetLeft: pauseBudgetMs, used: 0 };
|
|
788
|
+
const run = new PauseRun();
|
|
789
|
+
const host = pauseHost(allowedAppIds, allowedApps, clock);
|
|
790
|
+
active = { run, host };
|
|
705
791
|
const results = [];
|
|
706
792
|
let failed = 0;
|
|
707
|
-
const left = () =>
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
793
|
+
const left = () => clock.at - Date.now();
|
|
794
|
+
try {
|
|
795
|
+
for (let i = start; i < steps.length; i++) {
|
|
796
|
+
const step = steps[i];
|
|
797
|
+
const kind = String(step.kind || "");
|
|
798
|
+
let r;
|
|
799
|
+
let expectFailed = false;
|
|
800
|
+
if (left() < 200) {
|
|
801
|
+
r = errorResult(kind, "timeout", "The batch ran out of time before this step.");
|
|
802
|
+
}
|
|
803
|
+
else if (kind === "keep_awake" || kind === "handoff") {
|
|
804
|
+
r = await runLocal(kind, step);
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
const { expect: cond, onError: _onError, expectTimeoutMs, ...own } = step;
|
|
808
|
+
// Pointer steps other than the last skip their own after-step observation, as in the desktop app: a path
|
|
809
|
+
// step otherwise waited ~0.1 s for a UI event a canvas never sends (1200 strokes: 160 s instead of ~20 s).
|
|
810
|
+
const plain = QUIET_POINTER_KINDS.has(kind) && i < steps.length - 1 && own.observe === undefined ? { ...own, observe: "none" } : own;
|
|
811
|
+
const pathStart = i === start && plain.kind === "path" && resume ? resume : null;
|
|
812
|
+
const out = await pauseRunStep(host, run, plain, `${id}.${i + 1}`, pathStart);
|
|
813
|
+
if ("stop" in out) {
|
|
814
|
+
// Someone took over and the run cannot go on safely: say where, and how to continue (resumeFrom).
|
|
815
|
+
try {
|
|
816
|
+
helper?.release();
|
|
817
|
+
}
|
|
818
|
+
catch { /* best effort */ }
|
|
819
|
+
const at = kind === "path" ? cleanPosition(out.stop.at ?? (pathStart ?? {})) : {};
|
|
820
|
+
const resumeFrom = { step: i, ...(kind === "path" ? at : {}) };
|
|
821
|
+
const where = `at step ${i + 1} of ${steps.length} (${kind}${kind === "path" ? `, stroke ${(at.stroke ?? 0) + 1}` : ""})`;
|
|
822
|
+
return pauseStopResult("batch", out.stop, pauseStopText(out.stop, where, resumeFrom), Date.now() - started, results, resumeFrom, run.report());
|
|
823
|
+
}
|
|
824
|
+
r = out.result;
|
|
825
|
+
if (r.ok && cond && typeof cond === "object") {
|
|
826
|
+
const timeoutMs = Math.min(Math.max(100, Number(expectTimeoutMs) || 2_000), Math.max(100, left() - 100));
|
|
827
|
+
// Checked on the window the step acts in: its own windowId, else the batch's (windowTitleContains never
|
|
828
|
+
// matches some other window, 2026-10-24, with helper 1.2.2).
|
|
829
|
+
const expectWindow = typeof plain.windowId === "string" ? plain.windowId : typeof action.windowId === "string" ? action.windowId : "";
|
|
830
|
+
const ex = await runOnHelper(`${id}.${i + 1}e`, { kind: "expect", condition: cond, timeoutMs, ...(expectWindow ? { windowId: expectWindow } : {}) }, allowedAppIds, allowedApps, timeoutMs + 500);
|
|
831
|
+
if (!ex.ok) {
|
|
832
|
+
expectFailed = true;
|
|
833
|
+
const em = ex.error?.message || "the expectation failed";
|
|
834
|
+
r = { ok: false, kind, error: { code: ex.error?.code || "timeout", message: `The step ran but its expectation failed: ${em}` }, elapsedMs: r.elapsedMs };
|
|
835
|
+
}
|
|
732
836
|
}
|
|
733
837
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
838
|
+
results.push(r);
|
|
839
|
+
if (!r.ok) {
|
|
840
|
+
failed++;
|
|
841
|
+
const code = String(r.error?.code || "internal");
|
|
842
|
+
const onError = step.onError === "continue" ? false : step.onError === "stop" ? true : stopDefault;
|
|
843
|
+
// A failed expectation follows onError; a stop, a timeout or a person at the keyboard always ends the batch.
|
|
844
|
+
const forced = !expectFailed && ["stopped", "timeout", "user_interrupted", "screen_locked", "secure_desktop"].includes(code);
|
|
845
|
+
if (onError || forced) {
|
|
846
|
+
const msg = r.error?.message || "";
|
|
847
|
+
// A batch that did not finish leaves nothing pressed (hold_key / mouse down without their up).
|
|
848
|
+
try {
|
|
849
|
+
helper?.release();
|
|
850
|
+
}
|
|
851
|
+
catch { /* best effort */ }
|
|
852
|
+
const pauses = run.report();
|
|
853
|
+
return { ok: false, kind: "batch", error: { code, message: `Step ${i + 1} failed: ${msg}` }, steps: results, elapsedMs: Date.now() - started, ...(pauses ? { pauses } : {}) };
|
|
747
854
|
}
|
|
748
|
-
catch { /* best effort */ }
|
|
749
|
-
return { ok: false, kind: "batch", error: { code, message: `Step ${i + 1} failed: ${msg}` }, steps: results, elapsedMs: Date.now() - started };
|
|
750
855
|
}
|
|
751
856
|
}
|
|
752
857
|
}
|
|
858
|
+
finally {
|
|
859
|
+
if (active?.run === run)
|
|
860
|
+
active = null;
|
|
861
|
+
}
|
|
862
|
+
const pauses = run.report();
|
|
753
863
|
const text = failed === 0 ? `All ${results.length} steps done.` : `${results.length} steps run, ${failed} failed (onError continue).`;
|
|
754
|
-
return {
|
|
864
|
+
return {
|
|
865
|
+
ok: true, kind: "batch", text: pauses ? `${text}\n${pauseReportText(pauses)}` : text, steps: results, elapsedMs: Date.now() - started,
|
|
866
|
+
...(pauses ? { status: "completed_with_pauses", pauses } : {}),
|
|
867
|
+
};
|
|
868
|
+
};
|
|
869
|
+
/** One action that sends input: it waits while someone uses the computer and resumes a path where it was. */
|
|
870
|
+
const runPausable = async (id, action, allowedAppIds, allowedApps, deadlineMs, pauseBudgetMs) => {
|
|
871
|
+
const started = Date.now();
|
|
872
|
+
const kind = String(action.kind || "screen");
|
|
873
|
+
const clock = { at: started + deadlineMs, budgetLeft: pauseBudgetMs, used: 0 };
|
|
874
|
+
const run = new PauseRun();
|
|
875
|
+
const host = pauseHost(allowedAppIds, allowedApps, clock);
|
|
876
|
+
active = { run, host };
|
|
877
|
+
try {
|
|
878
|
+
const resume = kind === "path" ? pausePositionOf(action.resumeFrom) : null;
|
|
879
|
+
const out = await pauseRunStep(host, run, action, id, resume);
|
|
880
|
+
if ("stop" in out) {
|
|
881
|
+
try {
|
|
882
|
+
helper?.release();
|
|
883
|
+
}
|
|
884
|
+
catch { /* best effort */ }
|
|
885
|
+
if (kind === "path") {
|
|
886
|
+
const at = cleanPosition(out.stop.at ?? resume ?? {});
|
|
887
|
+
return pauseStopResult(kind, out.stop, pauseStopText(out.stop, `at stroke ${(at.stroke ?? 0) + 1}`, at), Date.now() - started, undefined, at, run.report());
|
|
888
|
+
}
|
|
889
|
+
return pauseStopResult(kind, out.stop, `${out.stop.message} Melaya stopped before ${kind} ran (nothing of it was done). Read the screen first, then send it again, or ask the user.`, Date.now() - started, undefined, undefined, run.report());
|
|
890
|
+
}
|
|
891
|
+
// elapsedMs of a command that paused covers the pauses too (callers subtract pausedMs, like an approval wait).
|
|
892
|
+
const r = out.result;
|
|
893
|
+
const pauses = run.report();
|
|
894
|
+
return withPauses(pauses ? { ...r, elapsedMs: Math.max(Number(r.elapsedMs) || 0, Date.now() - started) } : r, pauses);
|
|
895
|
+
}
|
|
896
|
+
finally {
|
|
897
|
+
if (active?.run === run)
|
|
898
|
+
active = null;
|
|
899
|
+
}
|
|
755
900
|
};
|
|
756
901
|
/** keep_awake and handoff, run by the host. */
|
|
757
902
|
const runLocal = async (kind, action) => {
|
|
@@ -773,10 +918,14 @@ export function createCliComputerHost(options = {}) {
|
|
|
773
918
|
return check.result;
|
|
774
919
|
const kind = String(check.action.kind);
|
|
775
920
|
const id = typeof msg.id === "string" && msg.id ? msg.id : `cli${Date.now()}`;
|
|
921
|
+
const budget = Math.floor(Number(msg.pauseBudgetMs));
|
|
922
|
+
const pauseBudgetMs = Number.isFinite(budget) && budget > 0 ? Math.min(MAX_PAUSE_BUDGET_MS, budget) : 0;
|
|
776
923
|
if (kind === "keep_awake" || kind === "handoff")
|
|
777
924
|
return runLocal(kind, check.action);
|
|
778
925
|
if (kind === "batch")
|
|
779
|
-
return runBatch(id, check.action, check.allowedAppIds, check.allowedApps, check.deadlineMs);
|
|
926
|
+
return runBatch(id, check.action, check.allowedAppIds, check.allowedApps, check.deadlineMs, pauseBudgetMs);
|
|
927
|
+
if (pauseNeedsIdle(kind))
|
|
928
|
+
return runPausable(id, check.action, check.allowedAppIds, check.allowedApps, check.deadlineMs, pauseBudgetMs);
|
|
780
929
|
return runOnHelper(id, check.action, check.allowedAppIds, check.allowedApps, check.deadlineMs);
|
|
781
930
|
};
|
|
782
931
|
const api = {
|
|
@@ -802,6 +951,8 @@ export function createCliComputerHost(options = {}) {
|
|
|
802
951
|
capabilities,
|
|
803
952
|
stopAll(reason) {
|
|
804
953
|
log(` [computer] stop (${String(reason || "stop").slice(0, 200)})`);
|
|
954
|
+
// A paused run stops waiting at once (Stop always wins over pause and resume).
|
|
955
|
+
stopGen++;
|
|
805
956
|
helper?.cancel();
|
|
806
957
|
keepAwake.release();
|
|
807
958
|
},
|
|
@@ -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
|
package/dist/computerRelay.js
CHANGED
|
@@ -36,6 +36,8 @@ export const COMPUTER_EVENTS = {
|
|
|
36
36
|
export const UNSUPPORTED_MESSAGE = "Computer control needs the Melaya desktop app on this computer.";
|
|
37
37
|
const MIN_DEADLINE_MS = 1_000;
|
|
38
38
|
const MAX_DEADLINE_MS = 31 * 60_000;
|
|
39
|
+
/** Most pause time (user at the keyboard) one command may add to its deadline (contract COMPUTER_PAUSE.budgetMs, with room). */
|
|
40
|
+
const MAX_PAUSE_BUDGET_MS = 300_000;
|
|
39
41
|
const MAX_PENDING = 64;
|
|
40
42
|
// The CLI host module is optional (it may be absent from a build): load it by
|
|
41
43
|
// a variable specifier so this file compiles and runs without it.
|
|
@@ -173,14 +175,19 @@ export function attachComputerRelay(socket, opts = {}) {
|
|
|
173
175
|
const deadlineMs = Math.min(MAX_DEADLINE_MS, Math.max(MIN_DEADLINE_MS, Math.floor(Number(p.deadlineMs) || 15_000)));
|
|
174
176
|
const allowedAppIds = Array.isArray(p.allowedAppIds) ? p.allowedAppIds.filter((x) => typeof x === "string") : [];
|
|
175
177
|
const allowedApps = Array.isArray(p.allowedApps) ? p.allowedApps : undefined;
|
|
178
|
+
// Pause and resume (2026-10-23): time the executor may spend paused for the user on top of the deadline
|
|
179
|
+
// (sent only to executors that report pauseResume; the server waits that much longer too).
|
|
180
|
+
const budget = Math.floor(Number(p.pauseBudgetMs));
|
|
181
|
+
const pauseBudgetMs = Number.isFinite(budget) && budget > 0 ? Math.min(MAX_PAUSE_BUDGET_MS, budget) : 0;
|
|
176
182
|
const msg = {
|
|
177
183
|
type: "computer.command", id, action, deadlineMs, allowedAppIds,
|
|
178
184
|
...(allowedApps ? { allowedApps } : {}),
|
|
185
|
+
...(pauseBudgetMs ? { pauseBudgetMs } : {}),
|
|
179
186
|
};
|
|
180
187
|
const startedAt = Date.now();
|
|
181
188
|
const timer = setTimeout(() => {
|
|
182
189
|
settle(id, errorResult(kind, "timeout", "The computer did not finish before the deadline. Read the screen before retrying: the action may or may not have happened.", Date.now() - startedAt));
|
|
183
|
-
}, deadlineMs);
|
|
190
|
+
}, deadlineMs + pauseBudgetMs);
|
|
184
191
|
pending.set(id, { ack, kind, startedAt, timer });
|
|
185
192
|
if (hosted) {
|
|
186
193
|
log(` [computer] ${kind} -> desktop app`);
|
package/dist/hostChannel.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@melaya/runner",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
|
-
"license": "Apache-2.0",
|
|
6
|
-
"author": "Melaya Labs LLC",
|
|
7
|
-
"private": false,
|
|
8
|
-
"type": "module",
|
|
9
|
-
"bin": {
|
|
10
|
-
"melaya-runner": "dist/cli.js"
|
|
11
|
-
},
|
|
12
|
-
"main": "dist/index.js",
|
|
13
|
-
"files": [
|
|
14
|
-
"dist/**/*.js",
|
|
15
|
-
"dist/**/*.d.ts",
|
|
16
|
-
"dist/**/*.py",
|
|
17
|
-
"localRagIngest.py",
|
|
18
|
-
"localRagRetrieve.py",
|
|
19
|
-
"localDbProbe.py",
|
|
20
|
-
"localBacklinkFetch.py",
|
|
21
|
-
"localVault.py",
|
|
22
|
-
"localVaultMirror.py",
|
|
23
|
-
"nltk_data/**",
|
|
24
|
-
"README.md",
|
|
25
|
-
"LICENSE"
|
|
26
|
-
],
|
|
27
|
-
"scripts": {
|
|
28
|
-
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('localDbProbe.py','dist/localDbProbe.py'); fs.copyFileSync('localBacklinkFetch.py','dist/localBacklinkFetch.py'); fs.copyFileSync('localVault.py','dist/localVault.py'); fs.copyFileSync('localVaultMirror.py','dist/localVaultMirror.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
29
|
-
"test": "node --test --import tsx \"src/**/*.test.ts\"",
|
|
30
|
-
"prepublishOnly": "npm run build"
|
|
31
|
-
},
|
|
32
|
-
"dependencies": {
|
|
33
|
-
"chalk": "^5.3.0",
|
|
34
|
-
"commander": "^12.0.0",
|
|
35
|
-
"ora": "^8.0.0",
|
|
36
|
-
"playwright": "^1.48.0",
|
|
37
|
-
"socket.io-client": "^4.8.0"
|
|
38
|
-
},
|
|
39
|
-
"devDependencies": {
|
|
40
|
-
"@types/node": "^20.0.0",
|
|
41
|
-
"tsx": "^4.19.2",
|
|
42
|
-
"typescript": "^5.5.0"
|
|
43
|
-
},
|
|
44
|
-
"engines": {
|
|
45
|
-
"node": ">=18"
|
|
46
|
-
},
|
|
47
|
-
"publishConfig": {
|
|
48
|
-
"access": "public"
|
|
49
|
-
}
|
|
50
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@melaya/runner",
|
|
3
|
+
"version": "1.1.76",
|
|
4
|
+
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Melaya Labs LLC",
|
|
7
|
+
"private": false,
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"melaya-runner": "dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/**/*.js",
|
|
15
|
+
"dist/**/*.d.ts",
|
|
16
|
+
"dist/**/*.py",
|
|
17
|
+
"localRagIngest.py",
|
|
18
|
+
"localRagRetrieve.py",
|
|
19
|
+
"localDbProbe.py",
|
|
20
|
+
"localBacklinkFetch.py",
|
|
21
|
+
"localVault.py",
|
|
22
|
+
"localVaultMirror.py",
|
|
23
|
+
"nltk_data/**",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('localDbProbe.py','dist/localDbProbe.py'); fs.copyFileSync('localBacklinkFetch.py','dist/localBacklinkFetch.py'); fs.copyFileSync('localVault.py','dist/localVault.py'); fs.copyFileSync('localVaultMirror.py','dist/localVaultMirror.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
29
|
+
"test": "node --test --import tsx \"src/**/*.test.ts\"",
|
|
30
|
+
"prepublishOnly": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"chalk": "^5.3.0",
|
|
34
|
+
"commander": "^12.0.0",
|
|
35
|
+
"ora": "^8.0.0",
|
|
36
|
+
"playwright": "^1.48.0",
|
|
37
|
+
"socket.io-client": "^4.8.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20.0.0",
|
|
41
|
+
"tsx": "^4.19.2",
|
|
42
|
+
"typescript": "^5.5.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|