@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.
@@ -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 (40 if (connector_services or _turn_connectors_mode()) else 8))),
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;
@@ -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
- /** Real user input seen this recently pauses a batch (`user_interrupted`). */
591
- const USER_INPUT_PAUSE_MS = 1_500;
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
- log(" [computer] someone is using this computer: runs pause");
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
- caps = { ...c, kinds: COMPUTER_ACTION_KINDS.filter((k) => kinds.has(k)), host: "cli" };
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
- /** batch, run here step by step (PROTOCOL.md §7): per-step expect, onError, user-input pause, shared deadline. */
701
- const runBatch = async (id, action, allowedAppIds, allowedApps, deadlineMs) => {
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 = () => deadlineMs - (Date.now() - started);
708
- for (let i = 0; i < steps.length; i++) {
709
- const step = steps[i];
710
- const kind = String(step.kind || "");
711
- let r;
712
- let expectFailed = false;
713
- if (i > 0 && Date.now() - lastUserInput < USER_INPUT_PAUSE_MS) {
714
- r = errorResult(kind, "user_interrupted", "Someone started using the mouse or keyboard, so the batch paused here. Read the screen before continuing.");
715
- }
716
- else if (left() < 200) {
717
- r = errorResult(kind, "timeout", "The batch ran out of time before this step.");
718
- }
719
- else if (kind === "keep_awake" || kind === "handoff") {
720
- r = await runLocal(kind, step);
721
- }
722
- else {
723
- const { expect: cond, onError: _onError, expectTimeoutMs, ...plain } = step;
724
- r = await runOnHelper(`${id}.${i + 1}`, plain, allowedAppIds, allowedApps, Math.max(500, left()));
725
- if (r.ok && cond && typeof cond === "object") {
726
- const timeoutMs = Math.min(Math.max(100, Number(expectTimeoutMs) || 2_000), Math.max(100, left() - 100));
727
- const ex = await runOnHelper(`${id}.${i + 1}e`, { kind: "expect", condition: cond, timeoutMs }, allowedAppIds, allowedApps, timeoutMs + 500);
728
- if (!ex.ok) {
729
- expectFailed = true;
730
- const em = ex.error?.message || "the expectation failed";
731
- r = { ok: false, kind, error: { code: ex.error?.code || "timeout", message: `The step ran but its expectation failed: ${em}` }, elapsedMs: r.elapsedMs };
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
- results.push(r);
736
- if (!r.ok) {
737
- failed++;
738
- const code = String(r.error?.code || "internal");
739
- const onError = step.onError === "continue" ? false : step.onError === "stop" ? true : stopDefault;
740
- // A failed expectation follows onError; a stop, a timeout or a person at the keyboard always ends the batch.
741
- const forced = !expectFailed && ["stopped", "timeout", "user_interrupted", "screen_locked", "secure_desktop"].includes(code);
742
- if (onError || forced) {
743
- const msg = r.error?.message || "";
744
- // A batch that did not finish leaves nothing pressed (hold_key / mouse down without their up).
745
- try {
746
- helper?.release();
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 { ok: true, kind: "batch", text, steps: results, elapsedMs: Date.now() - started };
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
  },