browserzap 0.5.5 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1 (2026-10-30)
4
+
5
+ The changes from three real-Chrome runs in the owner's own browser (docs/REVIEW_LATER.md, K-7…K-10). Checked in the
6
+ owner's Chrome: `npm run check:chrome` 9/9, approval through the page card, the test tab closed at session end.
7
+
8
+ - **No more tabs left behind.** The tab group is the contract: while the agent is connected its tabs stay open in the
9
+ BrowserZap group, and when the session ends **every tab still in that group closes — including the one on screen**.
10
+ Before this the active tab of the focused window was kept and ungrouped, so every run left a duplicate tab and
11
+ repeated runs piled them up (K-7). A tab you drag **out of** the group is yours and stays open. The one other
12
+ exception is a hand-off you were actually typing in (or a page that moved while it was open): that page is kept,
13
+ ungrouped, and closed never.
14
+ - **A note instead of silence.** On a tab kept that way, a short dismissal note says *"The agent finished — this tab is
15
+ yours."* It goes on its own after a few seconds (K-10).
16
+ - **Approve on the page.** A consequential action now draws an **Approve once** / **Deny** card beside the element it
17
+ marks, so the side panel never has to be opened by hand — Chrome will not open it on its own, which is why the ringed
18
+ button looked like the thing to press (K-8). The card, like the Stop pill and Done, acts only on a real click, is out
19
+ of the tab order and out of the agent's reach (it steps aside when the agent's own click is due to land there), and
20
+ the extension re-checks the approval is still pending for that session and tab. The toolbar icon is badged while a
21
+ decision or a hand-off waits, and cleared after. The side panel's **Approve once** still works.
22
+ - **The "Your turn" card says what happens next.** It now reads *type here, then press Done — don't submit the form; the
23
+ agent continues after you press Done*. If the page moves while the hand-off is open (a login redirect, or you submit
24
+ the form yourself), the card on the new page says the page changed and **Done** still hands control back (K-9).
25
+ - `npm run check:chrome` puts every instruction on the page it is about, not only in the terminal, and says explicitly
26
+ to press **Approve once** on the card next to the button — or in the side panel — and not the page's own button.
27
+
28
+ ## 0.6.0 (2026-10-30)
29
+
30
+ A replay now waits for what the step is actually blocked on, and retries that step instead of going back.
31
+
32
+ - **The resolver engine.** When a step fails, or a click lands but changes nothing, BrowserZap works out what it is
33
+ waiting for — a slow request, a spinner, a target that is not usable yet, a target that has not appeared — waits for
34
+ exactly that, then retries the **same step**. Up to twice per step and 45 seconds of waiting, inside the run's own
35
+ limit. A failed step is never replayed from step 1 unless that is where it failed.
36
+ - **Clicks are still never repeated blindly.** A consequential click (submit, pay, delete, or one that needed your
37
+ approval) is never retried. A click is retried only when it provably did not happen, or when it was reversible,
38
+ changed nothing at all **and started no request**. A click that started a request is never clicked again.
39
+ - **New wait conditions:** an element is usable (visible, enabled, uncovered and not moving), a spinner or "Loading…"
40
+ is gone, the page's network is quiet, or a specific request has answered. A 4xx/5xx answers at once instead of
41
+ waiting. A dashboard that refreshes itself every few seconds no longer looks busy forever, and analytics traffic is
42
+ ignored.
43
+ - **Learns each step.** After three runs a step's wait budget comes from what it usually needs; a blocker it hit in
44
+ at least 60 % of its **last 10 runs** is waited for *before* acting, and one that stops happening stops being waited
45
+ for, so a site that was fixed is not slowed down; a wait that never helps is skipped there. You can override with
46
+ `patience` (`fast`/`normal`/`patient`) or `maxWaitMs` on a step. The saved workflow is never changed automatically;
47
+ hints appear in the run result.
48
+ - **Remembers what a step produced.** The recorder saves the dialog that appeared, the request the step started
49
+ (method plus a path with ids replaced by `*`) and how long the page took to settle. On replay a step is done when
50
+ that is there, waiting at most the settle the recording measured (or your `maxWaitMs`) — a bounded check that ends
51
+ as soon as the evidence appears, never a pause. Never a value, a query string or page text.
52
+ - **Re-run failed resumes at the step that failed** when the page has not moved since the last verified step;
53
+ otherwise at the nearest earlier page load. It first checks, with a short bounded wait, that the failed step's
54
+ target is really on the page now — a page that moved goes back to the page load, and a workflow with no page load
55
+ of its own fails that set with `RESUME_UNVERIFIED` rather than acting on a page nobody checked. The batch summary
56
+ says how many sets resumed.
57
+ - No new tools and no new permission: still 34 tools, and the bridge stays loopback-only and authenticated.
58
+
3
59
  ## 0.5.5 (2026-10-30)
4
60
 
5
61
  Comment mode, debugging tools, files, Edge and WSL, a much better doctor, and the Chrome Web Store build. Known items to review are listed in docs/REVIEW_LATER.md (upload approval visibility, a file the person picks themselves, a download re-test, wider Comment screenshots, a calmer side panel).
@@ -13,6 +13,8 @@
13
13
  'use strict';
14
14
 
15
15
  const crypto = require('crypto');
16
+ const { LIMITS } = require('./limits');
17
+ const { resolveParams, substitute } = require('./workflows');
16
18
 
17
19
  /** Finished runs kept for workflow_status and rerunFailed. */
18
20
  const MAX_KEPT = 10;
@@ -25,6 +27,85 @@ class RunError extends Error {
25
27
  }
26
28
  }
27
29
 
30
+ /** Steps that only read the page: a resume may start at one without a probe. */
31
+ const RESUME_WITHOUT_PROBE = new Set(['wait_for_state', 'page_inspect', 'screenshot_capture', 'page_console', 'page_network']);
32
+
33
+ /** The nearest `browser_navigate` at or before `index`; -1 when there is none. */
34
+ function navigationAnchor(wf, index) {
35
+ let found = -1;
36
+ for (let i = 0; i <= index && Array.isArray(wf?.steps) && i < wf.steps.length; i++) {
37
+ if (wf.steps[i].tool === 'browser_navigate') found = i;
38
+ }
39
+ return found;
40
+ }
41
+
42
+ /**
43
+ * Where a set that did not complete may resume (0.6.0). It stays at the failed
44
+ * step when the page has not navigated since the last verified step; otherwise
45
+ * it starts at the nearest navigation anchor at or before the failure. Never
46
+ * step 1 unless that is the anchor.
47
+ */
48
+ function resumeAtFor(wf, run) {
49
+ if (!wf || !run || !Number.isInteger(run.stoppedAt)) return 0;
50
+ const checkpoint = Number.isInteger(run.checkpoint) ? run.checkpoint : -1;
51
+ const lastNavigation = navigationAnchor(wf, run.stoppedAt);
52
+ if (checkpoint >= 0 && checkpoint >= lastNavigation) return run.stoppedAt;
53
+ if (lastNavigation >= 0) return lastNavigation;
54
+ return 0;
55
+ }
56
+
57
+ /**
58
+ * A resume: where to start (`at`), the navigation anchor to fall back to when
59
+ * the page has moved (`anchor`, -1 when the workflow has none) and the failed
60
+ * step's own target (`target`), which is what a resume at that step must find
61
+ * actionable before it runs (A6-09). Values are not substituted here: the
62
+ * caller resolves them per parameter set.
63
+ */
64
+ function resumePlan(wf, run) {
65
+ const at = resumeAtFor(wf, run);
66
+ const anchor = Number.isInteger(run?.stoppedAt) ? navigationAnchor(wf, run.stoppedAt) : -1;
67
+ const target = wf?.steps?.[at]?.args?.target;
68
+ return { at, anchor, target: at > 0 && target && typeof target === 'object' ? target : null };
69
+ }
70
+
71
+ /**
72
+ * Prove the page is still where a resume at the failed step expects it.
73
+ *
74
+ * A real `wait_for_state { actionable: <the step's target> }` through the
75
+ * normal tool path, bounded by LIMITS.RESUME_PROBE_MS (a poll that ends as
76
+ * soon as the target is ready, never a pause). When it does not hold, the set
77
+ * starts at its navigation anchor instead. With no anchor there is nothing
78
+ * safe to start from, so it throws RESUME_UNVERIFIED: a set is never resumed
79
+ * with a real click on a page nobody checked.
80
+ *
81
+ * Returns the step to start at.
82
+ */
83
+ async function probeResume({ wf, plan, params = {}, execute, allowedOrigins = null, timeoutMs = LIMITS.RESUME_PROBE_MS }) {
84
+ const at = plan?.at ?? 0;
85
+ if (!(at > 0) || !execute) return at;
86
+ const tool = wf?.steps?.[at]?.tool;
87
+ let reason;
88
+ if (plan?.target) {
89
+ const target = substitute(plan.target, resolveParams(wf, params));
90
+ const env = await execute('wait_for_state', { actionable: target, timeoutMs }, { allowedOrigins, index: at });
91
+ if (env.ok) return at;
92
+ reason = env.error?.code ?? 'the probe did not hold';
93
+ } else if (tool === 'browser_navigate' || RESUME_WITHOUT_PROBE.has(tool)) {
94
+ return at;
95
+ } else {
96
+ // A key press with no target (Enter, say) acts on whatever has focus:
97
+ // there is nothing to probe, so it is never the first thing a resume does.
98
+ reason = 'the step has no target to check';
99
+ }
100
+ if (plan.anchor >= 0) return plan.anchor;
101
+ throw new RunError(
102
+ 'RESUME_UNVERIFIED',
103
+ `The page is not where step ${at + 1} of "${wf.name}" expects it (${reason}), ` +
104
+ 'and the workflow has no earlier page load to go back to, so nothing was run.',
105
+ 'Open the page the workflow starts on, or save it with a browser_navigate first, then run it from the start.',
106
+ );
107
+ }
108
+
28
109
  /**
29
110
  * @param {object} deps
30
111
  * @param {(job: object) => Promise<object>} deps.execute runs the batch for a job; resolves with the bulk result
@@ -43,7 +124,7 @@ function createRunRegistry({ execute, onFinish = () => {}, now = Date.now }) {
43
124
  * Start a batch. `paramSets` and `labels` are kept in memory only.
44
125
  * Throws RunError(TAB_BUSY) while another run is going.
45
126
  */
46
- function start({ wf, paramSets, labels = [], onError = 'continue' }) {
127
+ function start({ wf, paramSets, labels = [], onError = 'continue', resume = [] }) {
47
128
  const running = active();
48
129
  if (running) {
49
130
  throw new RunError('TAB_BUSY', `"${running.workflow}" is still running in the background (${running.runId}).`,
@@ -57,6 +138,7 @@ function createRunRegistry({ execute, onFinish = () => {}, now = Date.now }) {
57
138
  total: paramSets.length,
58
139
  paramSets,
59
140
  labels,
141
+ resume,
60
142
  wf,
61
143
  onError,
62
144
  state: 'running',
@@ -130,15 +212,19 @@ function createRunRegistry({ execute, onFinish = () => {}, now = Date.now }) {
130
212
  throw new RunError('TAB_BUSY', `"${job.workflow}" (${runId}) is still running.`, 'Wait for it to finish, or cancel it.');
131
213
  }
132
214
  const runs = job.result?.evidence?.runs ?? [];
133
- const finished = new Map(runs.map((r) => [r.index, r.ok]));
215
+ const finished = new Map(runs.map((r) => [r.index, r]));
134
216
  const indexes = [];
135
- for (let i = 0; i < job.total; i++) if (finished.get(i) !== true) indexes.push(i);
217
+ for (let i = 0; i < job.total; i++) if (finished.get(i)?.ok !== true) indexes.push(i);
136
218
  return {
137
219
  job,
138
220
  indexes,
139
221
  paramSets: indexes.map((i) => job.paramSets[i]),
140
222
  // A set keeps its name from the first run ("#3" stays "#3").
141
223
  labels: indexes.map((i) => job.labels[i] ?? `#${i + 1}`),
224
+ // Where each set may resume (0.6.0): the failed step, or the nearest
225
+ // navigation anchor when the page has moved since the last verified step.
226
+ // A resume at the failed step is probed first (probeResume, A6-09).
227
+ resume: indexes.map((i) => resumePlan(job.wf, finished.get(i))),
142
228
  };
143
229
  }
144
230
 
@@ -194,6 +280,7 @@ function compactBatch(result) {
194
280
  needAttention: ev.needAttention ?? attention.length,
195
281
  notRun: ev.notRun ?? 0,
196
282
  skippedSteps: (ev.runs ?? []).reduce((n, r) => n + (r.skipped?.length ?? 0), 0),
283
+ resolvedSteps: (ev.runs ?? []).reduce((n, r) => n + (r.resolvedSteps ?? 0), 0),
197
284
  recovered: (ev.runs ?? []).filter((r) => r.recoveries?.length).length,
198
285
  elapsedMs: ev.elapsedMs ?? null,
199
286
  report: ev.report ?? null,
@@ -201,4 +288,4 @@ function compactBatch(result) {
201
288
  };
202
289
  }
203
290
 
204
- module.exports = { createRunRegistry, compactBatch, RunError };
291
+ module.exports = { createRunRegistry, compactBatch, resumeAtFor, resumePlan, navigationAnchor, probeResume, RunError };
@@ -75,6 +75,8 @@ function describe(env) {
75
75
  `${f?.likelyCause ? `, ${f.likelyCause}` : ''}${f?.screenshot ? `\n screenshot: ${f.screenshot}` : ''}`);
76
76
  }
77
77
  if (env.evidence?.report) lines.push(`Report: ${env.evidence.report} (browserzap --runs)`);
78
+ const resolved = runs.reduce((n, r) => n + (r.resolvedSteps ?? 0), 0);
79
+ if (resolved) lines.push(` ${resolved} step(s) resolved in place (a wait, then the same step again).`);
78
80
  if (!runs.length && !env.ok && env.nextAction) lines.push(env.nextAction);
79
81
  return lines.join('\n');
80
82
  }
@@ -26,6 +26,13 @@ const LIMITS = Object.freeze({
26
26
  SETTLE_QUIET_MS: 500,
27
27
  /** How long that settle waits for a busy page (it costs nothing on a quiet one). */
28
28
  SETTLE_MAX_MS: 5000,
29
+ /** A wait whose condition makes no progress for this long ends early: a page that is stuck does not get better. */
30
+ STALL_MS: 3000,
31
+ /**
32
+ * Before "Re-run failed" resumes at the step that failed, that step's target
33
+ * must be actionable within this. A bounded probe, not a pause (A6-09).
34
+ */
35
+ RESUME_PROBE_MS: 3000,
29
36
  /** Longest per-step pacing a person may set on a saved workflow step. */
30
37
  MAX_STEP_PACING_MS: 30000,
31
38
  /** A tab with no agent action for this long is detached, so Chrome's "debugging" bar goes away (D-35). */
@@ -60,6 +67,16 @@ const STATUS_DEADLINE_MS = 4_000;
60
67
  */
61
68
  const RUN_DEADLINE_MS = 600_000;
62
69
 
70
+ /**
71
+ * Resolver engine bounds (`bridge-server/resolvers.js`, 0.6.0, D-37).
72
+ * A step may spend at most MAX_STEP_RESOLVE_MS resolving blockers in place,
73
+ * across at most MAX_RESOLVER_ATTEMPTS attempts, and a reversible click with
74
+ * no effect at all is repeated at most MAX_INPLACE_CLICK_RETRIES times (AU-08).
75
+ */
76
+ const MAX_STEP_RESOLVE_MS = 45_000;
77
+ const MAX_RESOLVER_ATTEMPTS = 2;
78
+ const MAX_INPLACE_CLICK_RETRIES = 2;
79
+
63
80
  /**
64
81
  * How long the caller waits for the extension to answer one call: the sum of
65
82
  * every wait the call can do, plus a margin, and never less than
@@ -97,4 +114,13 @@ function callDeadlineMs(tool, args = {}) {
97
114
  return Math.max(CALL_DEADLINE_MS, waits + DEADLINE_MARGIN_MS);
98
115
  }
99
116
 
100
- module.exports = { LIMITS, CALL_DEADLINE_MS, STATUS_DEADLINE_MS, RUN_DEADLINE_MS, callDeadlineMs };
117
+ module.exports = {
118
+ LIMITS,
119
+ CALL_DEADLINE_MS,
120
+ STATUS_DEADLINE_MS,
121
+ RUN_DEADLINE_MS,
122
+ MAX_STEP_RESOLVE_MS,
123
+ MAX_RESOLVER_ATTEMPTS,
124
+ MAX_INPLACE_CLICK_RETRIES,
125
+ callDeadlineMs,
126
+ };
@@ -41,7 +41,7 @@ const workflows = require('./workflows');
41
41
  const screenshots = require('./screenshots');
42
42
  const feedback = require('./feedback');
43
43
  const downloads = require('./downloads');
44
- const { createRunRegistry, compactBatch, RunError } = require('./background-runs');
44
+ const { createRunRegistry, compactBatch, probeResume, RunError } = require('./background-runs');
45
45
 
46
46
  const { WorkflowError, Recorder } = workflows;
47
47
 
@@ -929,7 +929,7 @@ async function runTool(requestId, traceId, args, progress) {
929
929
  * redaction as screenshot_capture).
930
930
  */
931
931
  async function runBatchOrOnce({
932
- wf, params, paramSets, labels, onError, startAt = 0, approvalId, signal,
932
+ wf, params, paramSets, labels, onError, startAt = 0, approvalId, signal, resume = [],
933
933
  onCall = () => {}, onStep = () => {}, onSet = () => {}, onSetDone = () => {},
934
934
  }) {
935
935
  const execute = async (tool, stepArgs, { allowedOrigins }) => {
@@ -946,14 +946,14 @@ async function runBatchOrOnce({
946
946
  const { env } = await executeTool('screenshot_capture', {}, { allowedOrigins: ALLOWED_ORIGINS });
947
947
  return env.ok ? env.screenshotPath ?? null : null;
948
948
  };
949
- const runOnce = async (setParams, first) => {
949
+ const runOnce = async (setParams, first, setStartAt = 0) => {
950
950
  const startedAt = new Date().toISOString();
951
951
  const history = workflows.runHistory(wf);
952
952
  const result = await workflows.run(wf, {
953
953
  history,
954
954
  onStep,
955
955
  params: setParams,
956
- startAt: first ? startAt : 0,
956
+ startAt: setStartAt || (first ? startAt : 0),
957
957
  approvalId: first ? approvalId : undefined,
958
958
  execute,
959
959
  capture,
@@ -964,7 +964,7 @@ async function runBatchOrOnce({
964
964
  service.recordRun({ wf, result, params: setParams, startedAt });
965
965
  return result;
966
966
  };
967
- if (paramSets === undefined) return runOnce(params, true);
967
+ if (paramSets === undefined) return runOnce(params, true, startAt);
968
968
  let first = true;
969
969
  return pro.runBulk({
970
970
  wf,
@@ -972,9 +972,22 @@ async function runBatchOrOnce({
972
972
  labels,
973
973
  onError,
974
974
  signal,
975
+ resume,
976
+ // A resume at the failed step is probed first, as a real wait_for_state
977
+ // through executeTool; the page has moved, so the set starts at the
978
+ // navigation anchor instead (A6-09). With no anchor it fails without acting.
979
+ resumeStart: ({ plan, params: setParams }) => probeResume({
980
+ wf, plan, params: setParams, execute, allowedOrigins: ALLOWED_ORIGINS,
981
+ }),
975
982
  onSet,
976
983
  onSetDone,
977
- runOnce: (setParams) => { const r = runOnce(setParams, first); first = false; return r; },
984
+ // A re-run of the sets that failed resumes at the step that failed where
985
+ // the page allows it (0.6.0), instead of starting every set at step 1.
986
+ runOnce: (setParams, setStartAt) => {
987
+ const r = runOnce(setParams, first, setStartAt ?? 0);
988
+ first = false;
989
+ return r;
990
+ },
978
991
  });
979
992
  }
980
993
 
@@ -985,6 +998,7 @@ const backgroundRuns = createRunRegistry({
985
998
  paramSets: job.paramSets,
986
999
  labels: job.labels,
987
1000
  onError: job.onError,
1001
+ resume: job.resume,
988
1002
  signal: job.controller.signal,
989
1003
  onCall: (c) => { job.call = c; },
990
1004
  onStep: ({ index }) => backgroundRuns.progress(job, { stepIndex: index }),
@@ -1040,14 +1054,16 @@ function statusOfRuns(traceId, args) {
1040
1054
  }
1041
1055
 
1042
1056
  function rerunFailedTool(traceId, args) {
1043
- const { job: previous, paramSets, labels } = backgroundRuns.failedSets(args.rerunFailed);
1057
+ const { job: previous, paramSets, labels, resume } = backgroundRuns.failedSets(args.rerunFailed);
1044
1058
  if (previous.workflow !== args.name) {
1045
1059
  return errorEnvelope(traceId, 'INVALID_ARGUMENT', `${previous.runId} ran "${previous.workflow}", not "${args.name}".`);
1046
1060
  }
1047
1061
  if (paramSets.length === 0) return okEnvelope(traceId, `Every set of ${previous.runId} completed; nothing to run again.`);
1048
- const job = backgroundRuns.start({ wf: workflows.load(previous.workflow), paramSets, labels, onError: 'continue' });
1062
+ const job = backgroundRuns.start({ wf: workflows.load(previous.workflow), paramSets, labels, onError: 'continue', resume });
1063
+ const resumed = resume.filter((plan) => plan?.at > 0).length;
1049
1064
  return okEnvelope(traceId,
1050
- `Running the ${paramSets.length} set(s) of ${previous.runId} that did not complete, from the start, in the background (${job.runId}).`,
1065
+ `Running the ${paramSets.length} set(s) of ${previous.runId} that did not complete` +
1066
+ `${resumed ? `, ${resumed} from the step that failed` : ''}, in the background (${job.runId}).`,
1051
1067
  { runId: job.runId }, { paramSets: paramSets.length, rerunOf: previous.runId });
1052
1068
  }
1053
1069
 
@@ -38,6 +38,7 @@ const { CALL_DEADLINE_MS } = require('./limits');
38
38
  const { createWorkflowService, checkStep, stepLogEntry } = require('./workflow-service');
39
39
  const { createSessionLog, describeResult } = require('./session-log');
40
40
  const { parseParamSets } = require('./param-sets');
41
+ const { resumePlan, probeResume } = require('./background-runs');
41
42
  const screenshots = require('./screenshots');
42
43
  const feedback = require('./feedback');
43
44
 
@@ -218,7 +219,13 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
218
219
  if (ok) current.batch.done += 1;
219
220
  else current.batch.failed += 1;
220
221
  },
221
- runOnce: (setParams) => runOnce(setParams, 0),
222
+ // "Re-run failed" probes the failed step's target before resuming there,
223
+ // as a real wait_for_state; a page that has moved starts at the
224
+ // navigation anchor, and with no anchor the set fails without acting (A6-09).
225
+ resumeStart: ({ plan, params: setParams }) => probeResume({
226
+ wf, plan, params: setParams, execute, allowedOrigins: wf.origins,
227
+ }),
228
+ runOnce: (setParams, setStartAt) => runOnce(setParams, setStartAt ?? 0),
222
229
  });
223
230
  const runs = result.evidence?.runs ?? [];
224
231
  lastBatch = {
@@ -226,6 +233,9 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
226
233
  paramSets: batch.paramSets,
227
234
  labels: batch.labels,
228
235
  unfinished: batch.paramSets.map((_, i) => i).filter((i) => !runs.some((r) => r.index === i && r.ok)),
236
+ // Where each set may resume when the person presses "Re-run failed" (0.6.0):
237
+ // the plan a probe proves before it is used (A6-09).
238
+ resumeByIndex: Object.fromEntries(runs.filter((r) => !r.ok).map((r) => [r.index, resumePlan(wf, r)])),
229
239
  };
230
240
  // What the person needs to find each problem: step, cause, screenshot. Never values.
231
241
  const attention = runs.filter((r) => !r.ok).map((r) => ({
@@ -241,7 +251,7 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
241
251
  status: result.ok ? 'completed' : controller.signal.aborted ? 'cancelled' : 'failed',
242
252
  summary: result.summary,
243
253
  code: result.error?.code ?? null,
244
- nextAction: lastBatch.unfinished.length ? `${lastBatch.unfinished.length} set(s) did not complete. "Re-run failed" runs just those, from the start.` : null,
254
+ nextAction: lastBatch.unfinished.length ? `${lastBatch.unfinished.length} set(s) did not complete. "Re-run failed" runs just those, from the step that failed where the page allows it.` : null,
245
255
  canContinueFrom: null,
246
256
  batch: { ...current.batch, attention, report: result.evidence?.report ?? null, canRerun: lastBatch.unfinished.length > 0 },
247
257
  finishedAt: new Date().toISOString(),
@@ -396,7 +406,8 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
396
406
  const wf = workflows.load(lastBatch.workflow);
397
407
  const paramSets = lastBatch.unfinished.map((i) => lastBatch.paramSets[i]);
398
408
  const labels = lastBatch.unfinished.map((i) => lastBatch.labels[i] ?? `#${i + 1}`);
399
- runWorkflow(wf, {}, 0, profile, { paramSets, labels }).catch((err) => log('panel_run_error', { message: err.message }));
409
+ const resume = lastBatch.unfinished.map((i) => lastBatch.resumeByIndex?.[i] ?? null);
410
+ runWorkflow(wf, {}, 0, profile, { paramSets, labels, resume }).catch((err) => log('panel_run_error', { message: err.message }));
400
411
  return { ok: true, data: { runId: current.runId, sets: paramSets.length } };
401
412
  }
402
413
  case 'run':