@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.
- package/dist/assistantHost.py +75 -3
- package/dist/computerHostCli.d.ts +10 -1
- package/dist/computerHostCli.js +191 -49
- package/dist/computerPause.d.ts +179 -0
- package/dist/computerPause.js +488 -0
- package/dist/computerRelay.js +8 -1
- package/dist/connection.js +15 -1
- package/dist/hostChannel.d.ts +1 -0
- package/dist/stepPlans.d.ts +37 -0
- package/dist/stepPlans.js +103 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -149,7 +149,9 @@ def _config_hash() -> str:
|
|
|
149
149
|
ONLY when the capability env is set, so a browser-less host hashes exactly
|
|
150
150
|
like a pre-browser server build (parity preserved). The TARGET GRANT itself
|
|
151
151
|
is NEVER hashed and never in the env at boot: it arrives per turn on the
|
|
152
|
-
turn frame and is purged at turn end (see _apply_browser_turn_grant).
|
|
152
|
+
turn frame and is purged at turn end (see _apply_browser_turn_grant).
|
|
153
|
+
Computer Control (protocol 5) follows the same rule: "computer" right after
|
|
154
|
+
"browser", only when MEL_ASSISTANT_COMPUTER_READY is set."""
|
|
153
155
|
import hashlib
|
|
154
156
|
raw_conn = os.environ.get("MEL_ASSISTANT_CONNECTORS", "") or ""
|
|
155
157
|
connectors = ",".join(sorted(c.lower() for c in raw_conn.split(",") if c.strip()))
|
|
@@ -162,6 +164,11 @@ def _config_hash() -> str:
|
|
|
162
164
|
]
|
|
163
165
|
if _browser_capable():
|
|
164
166
|
fields.append("browser")
|
|
167
|
+
# Computer Control PRESENCE (protocol 5), same rule as browser: appended
|
|
168
|
+
# only when on, right after "browser", byte-for-byte like
|
|
169
|
+
# runnerAssistantRelay.ts assistantConfigHash (COMPUTER_HASH_MARKER).
|
|
170
|
+
if _computer_ready():
|
|
171
|
+
fields.append("computer")
|
|
165
172
|
# Surgical tool pinning: PRESENCE of pinned tool names is part of the host
|
|
166
173
|
# boot config, exactly like browser capability above — a warm host that
|
|
167
174
|
# never registered a pinned tool active must reboot when a turn first
|
|
@@ -198,6 +205,14 @@ def _browser_capable() -> bool:
|
|
|
198
205
|
return (os.environ.get("MEL_ASSISTANT_BROWSER_CAPABLE", "") or "") in ("1", "true", "True")
|
|
199
206
|
|
|
200
207
|
|
|
208
|
+
def _computer_ready() -> bool:
|
|
209
|
+
"""Computer Control on this host (assistant protocol 5): the server sets
|
|
210
|
+
MEL_ASSISTANT_COMPUTER_READY when a computer is on for the chat (Devices
|
|
211
|
+
tile) or, without a selection, the user has one online. The server pins
|
|
212
|
+
every computer call to the chat's computers (/api/v1/computer/command)."""
|
|
213
|
+
return (os.environ.get("MEL_ASSISTANT_COMPUTER_READY", "") or "") in ("1", "true", "True")
|
|
214
|
+
|
|
215
|
+
|
|
201
216
|
def _render_summary_text(summary) -> str:
|
|
202
217
|
"""Render a server AssistantSummary ({facts, provenance}) to a data-only text
|
|
203
218
|
block for the compressed-summary slot. Never policy — reference only."""
|
|
@@ -918,10 +933,18 @@ def _build_agent():
|
|
|
918
933
|
# are trusted-UI-only and never part of this category (plan 0.1).
|
|
919
934
|
browser_enabled = _browser_capable()
|
|
920
935
|
|
|
936
|
+
# Computer Control (protocol 5): the computer_* toolkit (registry category
|
|
937
|
+
# "computer", shared/tools/computer.py) when the server says a computer is on
|
|
938
|
+
# for this chat. Each call posts to /api/v1/computer/command, where the
|
|
939
|
+
# server pins it to the chat's computers and applies the allowlist, app
|
|
940
|
+
# tiers, approvals and the kill switch.
|
|
941
|
+
computer_enabled = _computer_ready()
|
|
942
|
+
|
|
921
943
|
# Read-only platform tools (melaya_agent); phone control (Device Control) on
|
|
922
944
|
# mobile only. These POST to /api/v1/private/assistant-tool + /phone/command
|
|
923
945
|
# with MELAYA_API_KEY; tenant scope is enforced server-side.
|
|
924
|
-
categories = ["melaya_agent"] + (["phone"] if phone_enabled else []) + (["browser"] if browser_enabled else [])
|
|
946
|
+
categories = (["melaya_agent"] + (["phone"] if phone_enabled else []) + (["browser"] if browser_enabled else [])
|
|
947
|
+
+ (["computer"] if computer_enabled else []))
|
|
925
948
|
# Connector tool sets the user enabled for THIS chat (any service — odoo,
|
|
926
949
|
# stripe, shopify, …). When present, use a LAZY toolkit: base tools stay
|
|
927
950
|
# active+pinned, and the connectors' tools are deferred + discoverable via
|
|
@@ -977,6 +1000,12 @@ def _build_agent():
|
|
|
977
1000
|
# management + every browser action is directly callable, no search/activate.
|
|
978
1001
|
if browser_enabled:
|
|
979
1002
|
_budget = max(_budget, 64)
|
|
1003
|
+
# Same for the computer: ~38 computer_* tools must all stay pinned (no
|
|
1004
|
+
# search_tools/activate_tool per click, and an activated write would go
|
|
1005
|
+
# through the connector card on top of the server's own approval).
|
|
1006
|
+
# 64 holds melaya_agent + computer; each other device set adds ~40.
|
|
1007
|
+
if computer_enabled:
|
|
1008
|
+
_budget = max(_budget, 24 + 40 * (1 + int(phone_enabled) + int(browser_enabled)))
|
|
980
1009
|
# Surgical tool pinning: widen the budget so every pinned tool fits
|
|
981
1010
|
# alongside the base active set (melaya_agent + phone/browser if
|
|
982
1011
|
# enabled) — mirrors the phone_enabled/browser_enabled widening above,
|
|
@@ -1124,6 +1153,45 @@ def _build_agent():
|
|
|
1124
1153
|
"or route around it; tell the user if the task needs another site.\n"
|
|
1125
1154
|
if browser_enabled else ""
|
|
1126
1155
|
)
|
|
1156
|
+
# Computer Control discipline: a hand-mirror of the cloud Assistant's
|
|
1157
|
+
# computer guidance (assistantComputerTools.ts COMPUTER_PROMPT_HINT,
|
|
1158
|
+
# computerVerbs.ts COMPUTER_ROUTING / COMPUTER_LOOP / COMPUTER_LIMITS,
|
|
1159
|
+
# fastSchema.ts COMPUTER_FAST_GUIDANCE). Keep them in step.
|
|
1160
|
+
computer_rule = (
|
|
1161
|
+
"- COMPUTER CONTROL: you can operate the user's own computer (Windows, macOS or "
|
|
1162
|
+
"Linux, through the Melaya desktop app) with the computer_* tools. When the user "
|
|
1163
|
+
"asks whether you can access or use their computer, the answer is YES. Use the "
|
|
1164
|
+
"computer for desktop apps; prefer a connected service, the browser or the phone "
|
|
1165
|
+
"when they can do the job. Call computer_status first, and whenever a computer "
|
|
1166
|
+
"tool says the app is unreachable.\n"
|
|
1167
|
+
"- SEE THE SCREEN FIRST, then read, act, verify: read the window with "
|
|
1168
|
+
"computer_screen (computer_screenshot, computer_zoom or computer_ocr for a canvas, "
|
|
1169
|
+
"game, remote desktop or video that has no element tree), act on ONE element by "
|
|
1170
|
+
"its @eN ref or exact label, then check the result's observed change. A no_effect "
|
|
1171
|
+
"result means nothing changed: do not repeat the action, read again and pick "
|
|
1172
|
+
"another target. NEVER ask the user to describe, paste or screenshot their own "
|
|
1173
|
+
"screen: you have eyes, use them.\n"
|
|
1174
|
+
"- GO FAST: once you have read the window and can name the next steps, send them in "
|
|
1175
|
+
"ONE computer_fast call (click / type / select / press keys and chords / scroll / "
|
|
1176
|
+
"wait / expect / read by the names you read, collect for lists, for_each for rows) "
|
|
1177
|
+
"instead of one call per click; it re-finds every target, stops at the first "
|
|
1178
|
+
"surprise and hands the window back. computer_batch runs a known list of actions "
|
|
1179
|
+
"in one call; computer_path draws or traces strokes (canvas ink, signatures, "
|
|
1180
|
+
"sliders). Use the single computer_* tools to explore an unread window, for "
|
|
1181
|
+
"logins, secure fields and file dialogs.\n"
|
|
1182
|
+
"- APPROVALS AND LIMITS: writes follow this chat's autonomy mode, decided by the "
|
|
1183
|
+
"SYSTEM. If a result says an approval is waiting, a card is already in front of "
|
|
1184
|
+
"the user: re-issue the SAME call once, never ask in chat instead. Only apps the "
|
|
1185
|
+
"user allowed in Computer Control can be driven, each at its level (not_allowed / "
|
|
1186
|
+
"tier_denied: ask the user to allow it, never open another app or work around it). "
|
|
1187
|
+
"Never type into password, PIN, OTP or card fields (secure_field), and let the user "
|
|
1188
|
+
"answer admin / UAC prompts themselves (elevated_window, secure_desktop).\n"
|
|
1189
|
+
"- STOP: the user can stop you at any moment with the Stop button or the stop "
|
|
1190
|
+
"shortcut (Ctrl+Alt+End). After a stopped result make no more computer calls and "
|
|
1191
|
+
"report honestly what was done. Call computer_stop yourself the moment the user "
|
|
1192
|
+
"says stop or something goes wrong on screen.\n"
|
|
1193
|
+
if computer_enabled else ""
|
|
1194
|
+
)
|
|
1127
1195
|
# Surgical tool pinning: those exact tool names are already ACTIVE (not
|
|
1128
1196
|
# deferred), so the rule says to call them directly. Per-turn connectors
|
|
1129
1197
|
# (protocol 3): connector_services is empty here and the rule is rendered per
|
|
@@ -1197,12 +1265,15 @@ def _build_agent():
|
|
|
1197
1265
|
"mode the user approves each one). That is your CURRENT TOOLBOX, not a "
|
|
1198
1266
|
"description of Melaya"
|
|
1199
1267
|
+ (" — plus phone-control tools to drive the user's paired phone." if phone_enabled else ".")
|
|
1268
|
+
+ (" Computer-control tools (computer_*) drive the user's own computer through the "
|
|
1269
|
+
"Melaya desktop app." if computer_enabled else "")
|
|
1200
1270
|
+ "\nRules:\n"
|
|
1201
1271
|
"- Use tools to answer questions about the user's pipelines, spend, usage, "
|
|
1202
1272
|
"templates or evals — never invent numbers. For cost, melaya_cost_summary "
|
|
1203
1273
|
"supports dimension='pipeline' to find which pipeline cost the most.\n"
|
|
1204
1274
|
+ phone_rule
|
|
1205
1275
|
+ browser_rule
|
|
1276
|
+
+ computer_rule
|
|
1206
1277
|
+ connector_rule
|
|
1207
1278
|
+ core_rule
|
|
1208
1279
|
+ "- Be concise and concrete; format small tables or bullet lists when comparing items.\n"
|
|
@@ -1279,7 +1350,8 @@ def _build_agent():
|
|
|
1279
1350
|
# wall-clock budget is enforced separately in _run_turn.
|
|
1280
1351
|
max_iters=(int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_PHONE", "300") or "300") if phone_enabled
|
|
1281
1352
|
else (int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_BROWSER", "300") or "300") if browser_enabled
|
|
1282
|
-
else (
|
|
1353
|
+
else (int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_COMPUTER", "300") or "300") if computer_enabled
|
|
1354
|
+
else (40 if (connector_services or _turn_connectors_mode()) else 8)))),
|
|
1283
1355
|
# EVERY assistant turn is long-horizon, not only device-driving ones.
|
|
1284
1356
|
#
|
|
1285
1357
|
# The codex cap is a latency default written for pipeline phases. An
|
|
@@ -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
|
|
@@ -52,6 +55,7 @@ export type ComputerRunnerToHost = {
|
|
|
52
55
|
deadlineMs: number;
|
|
53
56
|
allowedAppIds: string[];
|
|
54
57
|
allowedApps?: AllowedApp[];
|
|
58
|
+
pauseBudgetMs?: number;
|
|
55
59
|
} | {
|
|
56
60
|
type: "computer.stop";
|
|
57
61
|
reason: string;
|
|
@@ -187,6 +191,11 @@ export declare class HelperProcess {
|
|
|
187
191
|
*/
|
|
188
192
|
/** Releases every key / button the helper holds (helper 1.2.0+; older helpers ignore the op). */
|
|
189
193
|
release(): void;
|
|
194
|
+
/**
|
|
195
|
+
* Cancels one command for a pause (pause and resume): unlike cancel(), its `stopped` reply (with the path's
|
|
196
|
+
* `progress`) still answers the request, so the host knows how far it got and continues from there.
|
|
197
|
+
*/
|
|
198
|
+
pauseCancel(target: string): void;
|
|
190
199
|
cancel(target?: string): void;
|
|
191
200
|
get running(): boolean;
|
|
192
201
|
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,7 @@ 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";
|
|
52
56
|
export const COMPUTER_ACTION_KINDS = [
|
|
53
57
|
"screen", "screenshot", "find", "get_text", "current_app", "list_windows", "list_apps", "list_displays",
|
|
54
58
|
"clipboard_read", "ocr", "wait", "hover", "move", "scroll",
|
|
@@ -472,6 +476,13 @@ export class HelperProcess {
|
|
|
472
476
|
release() {
|
|
473
477
|
this.send({ op: "release" });
|
|
474
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* Cancels one command for a pause (pause and resume): unlike cancel(), its `stopped` reply (with the path's
|
|
481
|
+
* `progress`) still answers the request, so the host knows how far it got and continues from there.
|
|
482
|
+
*/
|
|
483
|
+
pauseCancel(target) {
|
|
484
|
+
this.send({ op: "cancel", target });
|
|
485
|
+
}
|
|
475
486
|
cancel(target) {
|
|
476
487
|
this.send(target ? { op: "cancel", target } : { op: "cancel" });
|
|
477
488
|
if (!target || !this.child)
|
|
@@ -587,8 +598,30 @@ export function _resetCliComputerHost() {
|
|
|
587
598
|
cliEnabled = false;
|
|
588
599
|
cliDefaults = {};
|
|
589
600
|
}
|
|
590
|
-
/**
|
|
591
|
-
const
|
|
601
|
+
/** Most pause time (someone at the keyboard) one command may add to its deadline (contract COMPUTER_PAUSE.budgetMs, with room). */
|
|
602
|
+
const MAX_PAUSE_BUDGET_MS = 300_000;
|
|
603
|
+
/** A run that paused and could not go on: user_interrupted with where it stopped (a Stop keeps "stopped"). */
|
|
604
|
+
function pauseStopResult(kind, stop, message, elapsedMs, steps, resumeFrom, pauses) {
|
|
605
|
+
return {
|
|
606
|
+
...errorResult(kind, stop.reason === "stopped" ? "stopped" : "user_interrupted", message, elapsedMs),
|
|
607
|
+
...(steps ? { steps } : {}),
|
|
608
|
+
...(resumeFrom && stop.reason !== "stopped" ? { resumeFrom } : {}),
|
|
609
|
+
...(pauses ? { pauses } : {}),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/** A resume position the agent copies back (stroke / point / frac, rounded). */
|
|
613
|
+
function cleanPosition(p) {
|
|
614
|
+
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 };
|
|
615
|
+
}
|
|
616
|
+
/** A finished command that paused for the user: status completed_with_pauses and the compact report. */
|
|
617
|
+
function withPauses(r, pauses) {
|
|
618
|
+
if (!pauses)
|
|
619
|
+
return r;
|
|
620
|
+
if (!r.ok)
|
|
621
|
+
return { ...r, pauses };
|
|
622
|
+
const text = typeof r.text === "string" ? r.text : "";
|
|
623
|
+
return { ...r, text: `${text}${text ? "\n" : ""}${pauseReportText(pauses)}`, status: "completed_with_pauses", pauses };
|
|
624
|
+
}
|
|
592
625
|
export function createCliComputerHost(options = {}) {
|
|
593
626
|
const opts = { ...cliDefaults, ...options };
|
|
594
627
|
const env = opts.env ?? process.env;
|
|
@@ -604,6 +637,10 @@ export function createCliComputerHost(options = {}) {
|
|
|
604
637
|
let capsAt = 0;
|
|
605
638
|
let disposed = false;
|
|
606
639
|
let lastUserInput = 0;
|
|
640
|
+
/** Bumped by stopAll: a run started before it has ended (pause and resume stop waiting). */
|
|
641
|
+
let stopGen = 0;
|
|
642
|
+
/** The run in flight that pauses for the user (pause and resume, computerPause.ts). */
|
|
643
|
+
let active = null;
|
|
607
644
|
const keepAwake = opts.keepAwake ?? new KeepAwake({ spawn: opts.spawn, env });
|
|
608
645
|
const ensureHelper = async () => {
|
|
609
646
|
if (disposed)
|
|
@@ -624,7 +661,12 @@ export function createCliComputerHost(options = {}) {
|
|
|
624
661
|
onEvent: (e) => {
|
|
625
662
|
if (e.event === "user_input") {
|
|
626
663
|
lastUserInput = Date.now();
|
|
627
|
-
|
|
664
|
+
// Pause and resume: let go of what the run holds and cancel a pointer stream now; it goes on once they stop.
|
|
665
|
+
if (active) {
|
|
666
|
+
if (!active.run.interrupted)
|
|
667
|
+
log(" [computer] someone is using this computer: the run pauses until they stop");
|
|
668
|
+
active.run.onUserInput(active.host);
|
|
669
|
+
}
|
|
628
670
|
}
|
|
629
671
|
else if (e.event === "log") {
|
|
630
672
|
log(` [computer] ${String(e.message || "").slice(0, 300)}`);
|
|
@@ -654,7 +696,8 @@ export function createCliComputerHost(options = {}) {
|
|
|
654
696
|
kinds.add("keep_awake");
|
|
655
697
|
else
|
|
656
698
|
kinds.delete("keep_awake");
|
|
657
|
-
|
|
699
|
+
// pauseResume: this host pauses for the user and resumes (computerPause.ts), takes pauseBudgetMs and resumeFrom.
|
|
700
|
+
caps = { ...c, kinds: COMPUTER_ACTION_KINDS.filter((k) => kinds.has(k)), host: "cli", pauseResume: true };
|
|
658
701
|
}
|
|
659
702
|
else {
|
|
660
703
|
caps = null;
|
|
@@ -697,61 +740,154 @@ export function createCliComputerHost(options = {}) {
|
|
|
697
740
|
return errorResult(kind, "helper_unavailable", `The computer helper stopped: ${m}`, Date.now() - started);
|
|
698
741
|
}
|
|
699
742
|
};
|
|
700
|
-
/**
|
|
701
|
-
const
|
|
743
|
+
/** What the pause engine needs from this host for one command (every command still goes through runOnHelper). */
|
|
744
|
+
const pauseHost = (allowedAppIds, allowedApps, clock) => {
|
|
745
|
+
const gen = stopGen;
|
|
746
|
+
return {
|
|
747
|
+
now: () => Date.now(),
|
|
748
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
749
|
+
exec: (a, hid) => runOnHelper(hid, a, allowedAppIds, allowedApps, Math.max(500, clock.at - Date.now())),
|
|
750
|
+
cancel: (hid) => { try {
|
|
751
|
+
helper?.pauseCancel(hid);
|
|
752
|
+
}
|
|
753
|
+
catch { /* helper gone */ } },
|
|
754
|
+
release: () => { try {
|
|
755
|
+
helper?.release();
|
|
756
|
+
}
|
|
757
|
+
catch { /* best effort */ } },
|
|
758
|
+
ended: () => disposed || gen !== stopGen,
|
|
759
|
+
timeLeft: () => clock.at - Date.now(),
|
|
760
|
+
extend: (ms) => {
|
|
761
|
+
const d = ms > 0 ? Math.min(ms, clock.budgetLeft) : -Math.min(-ms, clock.used);
|
|
762
|
+
clock.budgetLeft -= d;
|
|
763
|
+
clock.used += d;
|
|
764
|
+
clock.at += d;
|
|
765
|
+
return d;
|
|
766
|
+
},
|
|
767
|
+
lastInputAt: () => lastUserInput,
|
|
768
|
+
markInput: () => { lastUserInput = Date.now(); },
|
|
769
|
+
overlay: (on) => log(on ? " [computer] paused: someone is using this computer; it resumes when they stop" : " [computer] resumed"),
|
|
770
|
+
quietMs: PAUSE_DEFAULTS.quietMs,
|
|
771
|
+
maxPauseMs: PAUSE_DEFAULTS.maxPauseMs,
|
|
772
|
+
};
|
|
773
|
+
};
|
|
774
|
+
/** batch, run here step by step (PROTOCOL.md §7): per-step expect, onError, pause and resume, shared deadline. */
|
|
775
|
+
const runBatch = async (id, action, allowedAppIds, allowedApps, deadlineMs, pauseBudgetMs = 0) => {
|
|
702
776
|
const started = Date.now();
|
|
703
777
|
const steps = action.steps || [];
|
|
704
778
|
const stopDefault = action.stopOnError !== false;
|
|
779
|
+
// resumeFrom (2026-10-23): continue a batch that stopped midway; a path at that step goes on at its stroke.
|
|
780
|
+
const resume = pausePositionOf(action.resumeFrom);
|
|
781
|
+
const start = resume?.step ?? 0;
|
|
782
|
+
if (start >= steps.length)
|
|
783
|
+
return errorResult("batch", "invalid_action", `resumeFrom.step ${start} is past the last step (the batch has ${steps.length}; steps count from 0).`);
|
|
784
|
+
const clock = { at: started + deadlineMs, budgetLeft: pauseBudgetMs, used: 0 };
|
|
785
|
+
const run = new PauseRun();
|
|
786
|
+
const host = pauseHost(allowedAppIds, allowedApps, clock);
|
|
787
|
+
active = { run, host };
|
|
705
788
|
const results = [];
|
|
706
789
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
const
|
|
731
|
-
|
|
790
|
+
const left = () => clock.at - Date.now();
|
|
791
|
+
try {
|
|
792
|
+
for (let i = start; i < steps.length; i++) {
|
|
793
|
+
const step = steps[i];
|
|
794
|
+
const kind = String(step.kind || "");
|
|
795
|
+
let r;
|
|
796
|
+
let expectFailed = false;
|
|
797
|
+
if (left() < 200) {
|
|
798
|
+
r = errorResult(kind, "timeout", "The batch ran out of time before this step.");
|
|
799
|
+
}
|
|
800
|
+
else if (kind === "keep_awake" || kind === "handoff") {
|
|
801
|
+
r = await runLocal(kind, step);
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
const { expect: cond, onError: _onError, expectTimeoutMs, ...plain } = step;
|
|
805
|
+
const pathStart = i === start && plain.kind === "path" && resume ? resume : null;
|
|
806
|
+
const out = await pauseRunStep(host, run, plain, `${id}.${i + 1}`, pathStart);
|
|
807
|
+
if ("stop" in out) {
|
|
808
|
+
// Someone took over and the run cannot go on safely: say where, and how to continue (resumeFrom).
|
|
809
|
+
try {
|
|
810
|
+
helper?.release();
|
|
811
|
+
}
|
|
812
|
+
catch { /* best effort */ }
|
|
813
|
+
const at = kind === "path" ? cleanPosition(out.stop.at ?? (pathStart ?? {})) : {};
|
|
814
|
+
const resumeFrom = { step: i, ...(kind === "path" ? at : {}) };
|
|
815
|
+
const where = `at step ${i + 1} of ${steps.length} (${kind}${kind === "path" ? `, stroke ${(at.stroke ?? 0) + 1}` : ""})`;
|
|
816
|
+
return pauseStopResult("batch", out.stop, pauseStopText(out.stop, where, resumeFrom), Date.now() - started, results, resumeFrom, run.report());
|
|
817
|
+
}
|
|
818
|
+
r = out.result;
|
|
819
|
+
if (r.ok && cond && typeof cond === "object") {
|
|
820
|
+
const timeoutMs = Math.min(Math.max(100, Number(expectTimeoutMs) || 2_000), Math.max(100, left() - 100));
|
|
821
|
+
const ex = await runOnHelper(`${id}.${i + 1}e`, { kind: "expect", condition: cond, timeoutMs }, allowedAppIds, allowedApps, timeoutMs + 500);
|
|
822
|
+
if (!ex.ok) {
|
|
823
|
+
expectFailed = true;
|
|
824
|
+
const em = ex.error?.message || "the expectation failed";
|
|
825
|
+
r = { ok: false, kind, error: { code: ex.error?.code || "timeout", message: `The step ran but its expectation failed: ${em}` }, elapsedMs: r.elapsedMs };
|
|
826
|
+
}
|
|
732
827
|
}
|
|
733
828
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
829
|
+
results.push(r);
|
|
830
|
+
if (!r.ok) {
|
|
831
|
+
failed++;
|
|
832
|
+
const code = String(r.error?.code || "internal");
|
|
833
|
+
const onError = step.onError === "continue" ? false : step.onError === "stop" ? true : stopDefault;
|
|
834
|
+
// A failed expectation follows onError; a stop, a timeout or a person at the keyboard always ends the batch.
|
|
835
|
+
const forced = !expectFailed && ["stopped", "timeout", "user_interrupted", "screen_locked", "secure_desktop"].includes(code);
|
|
836
|
+
if (onError || forced) {
|
|
837
|
+
const msg = r.error?.message || "";
|
|
838
|
+
// A batch that did not finish leaves nothing pressed (hold_key / mouse down without their up).
|
|
839
|
+
try {
|
|
840
|
+
helper?.release();
|
|
841
|
+
}
|
|
842
|
+
catch { /* best effort */ }
|
|
843
|
+
const pauses = run.report();
|
|
844
|
+
return { ok: false, kind: "batch", error: { code, message: `Step ${i + 1} failed: ${msg}` }, steps: results, elapsedMs: Date.now() - started, ...(pauses ? { pauses } : {}) };
|
|
747
845
|
}
|
|
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
846
|
}
|
|
751
847
|
}
|
|
752
848
|
}
|
|
849
|
+
finally {
|
|
850
|
+
if (active?.run === run)
|
|
851
|
+
active = null;
|
|
852
|
+
}
|
|
853
|
+
const pauses = run.report();
|
|
753
854
|
const text = failed === 0 ? `All ${results.length} steps done.` : `${results.length} steps run, ${failed} failed (onError continue).`;
|
|
754
|
-
return {
|
|
855
|
+
return {
|
|
856
|
+
ok: true, kind: "batch", text: pauses ? `${text}\n${pauseReportText(pauses)}` : text, steps: results, elapsedMs: Date.now() - started,
|
|
857
|
+
...(pauses ? { status: "completed_with_pauses", pauses } : {}),
|
|
858
|
+
};
|
|
859
|
+
};
|
|
860
|
+
/** One action that sends input: it waits while someone uses the computer and resumes a path where it was. */
|
|
861
|
+
const runPausable = async (id, action, allowedAppIds, allowedApps, deadlineMs, pauseBudgetMs) => {
|
|
862
|
+
const started = Date.now();
|
|
863
|
+
const kind = String(action.kind || "screen");
|
|
864
|
+
const clock = { at: started + deadlineMs, budgetLeft: pauseBudgetMs, used: 0 };
|
|
865
|
+
const run = new PauseRun();
|
|
866
|
+
const host = pauseHost(allowedAppIds, allowedApps, clock);
|
|
867
|
+
active = { run, host };
|
|
868
|
+
try {
|
|
869
|
+
const resume = kind === "path" ? pausePositionOf(action.resumeFrom) : null;
|
|
870
|
+
const out = await pauseRunStep(host, run, action, id, resume);
|
|
871
|
+
if ("stop" in out) {
|
|
872
|
+
try {
|
|
873
|
+
helper?.release();
|
|
874
|
+
}
|
|
875
|
+
catch { /* best effort */ }
|
|
876
|
+
if (kind === "path") {
|
|
877
|
+
const at = cleanPosition(out.stop.at ?? resume ?? {});
|
|
878
|
+
return pauseStopResult(kind, out.stop, pauseStopText(out.stop, `at stroke ${(at.stroke ?? 0) + 1}`, at), Date.now() - started, undefined, at, run.report());
|
|
879
|
+
}
|
|
880
|
+
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());
|
|
881
|
+
}
|
|
882
|
+
// elapsedMs of a command that paused covers the pauses too (callers subtract pausedMs, like an approval wait).
|
|
883
|
+
const r = out.result;
|
|
884
|
+
const pauses = run.report();
|
|
885
|
+
return withPauses(pauses ? { ...r, elapsedMs: Math.max(Number(r.elapsedMs) || 0, Date.now() - started) } : r, pauses);
|
|
886
|
+
}
|
|
887
|
+
finally {
|
|
888
|
+
if (active?.run === run)
|
|
889
|
+
active = null;
|
|
890
|
+
}
|
|
755
891
|
};
|
|
756
892
|
/** keep_awake and handoff, run by the host. */
|
|
757
893
|
const runLocal = async (kind, action) => {
|
|
@@ -773,10 +909,14 @@ export function createCliComputerHost(options = {}) {
|
|
|
773
909
|
return check.result;
|
|
774
910
|
const kind = String(check.action.kind);
|
|
775
911
|
const id = typeof msg.id === "string" && msg.id ? msg.id : `cli${Date.now()}`;
|
|
912
|
+
const budget = Math.floor(Number(msg.pauseBudgetMs));
|
|
913
|
+
const pauseBudgetMs = Number.isFinite(budget) && budget > 0 ? Math.min(MAX_PAUSE_BUDGET_MS, budget) : 0;
|
|
776
914
|
if (kind === "keep_awake" || kind === "handoff")
|
|
777
915
|
return runLocal(kind, check.action);
|
|
778
916
|
if (kind === "batch")
|
|
779
|
-
return runBatch(id, check.action, check.allowedAppIds, check.allowedApps, check.deadlineMs);
|
|
917
|
+
return runBatch(id, check.action, check.allowedAppIds, check.allowedApps, check.deadlineMs, pauseBudgetMs);
|
|
918
|
+
if (pauseNeedsIdle(kind))
|
|
919
|
+
return runPausable(id, check.action, check.allowedAppIds, check.allowedApps, check.deadlineMs, pauseBudgetMs);
|
|
780
920
|
return runOnHelper(id, check.action, check.allowedAppIds, check.allowedApps, check.deadlineMs);
|
|
781
921
|
};
|
|
782
922
|
const api = {
|
|
@@ -802,6 +942,8 @@ export function createCliComputerHost(options = {}) {
|
|
|
802
942
|
capabilities,
|
|
803
943
|
stopAll(reason) {
|
|
804
944
|
log(` [computer] stop (${String(reason || "stop").slice(0, 200)})`);
|
|
945
|
+
// A paused run stops waiting at once (Stop always wins over pause and resume).
|
|
946
|
+
stopGen++;
|
|
805
947
|
helper?.cancel();
|
|
806
948
|
keepAwake.release();
|
|
807
949
|
},
|