@remcp/runtime 0.2.225 → 0.2.227

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.225",
3
+ "version": "0.2.227",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -340,21 +340,31 @@ export async function uiSnapshot(args = {}) {
340
340
  ? args.active_only === true
341
341
  : (!explicitlyScoped && args.scope !== 'desktop');
342
342
  let effective = { ...args, active_only:activeOnly };
343
- let raw = await adapter.uiSnapshot(effective);
343
+ // For wait_for_ui, share one deadline across native capture and Linux retries.
344
+ const deadline = args.timeout_ms != null && Number.isFinite(Number(args.timeout_ms))
345
+ ? Date.now() + clamp(args.timeout_ms, 30_000, 100, 120_000) : null;
346
+ const canRetry = () => deadline === null || Date.now() + 100 < deadline;
347
+ const capture = () => adapter.uiSnapshot({
348
+ ...effective,
349
+ ...(deadline === null ? {} : { timeout_ms:Math.max(100, deadline - Date.now()) }),
350
+ });
351
+ let raw = await capture();
344
352
  let value = resultValue(raw);
345
353
  const requestedDepth = Number(effective.max_depth);
346
354
  const isEmptySnapshot = payload => Array.isArray(payload)
347
355
  ? payload.length === 0
348
356
  : Array.isArray(payload?.nodes) && payload.nodes.length === 0;
349
- if (process.platform === 'linux' && isEmptySnapshot(value)) {
350
- // GNOME/AT-SPI can transiently report an empty tree. Retry once at the requested
351
- // depth before reducing traversal depth, so valid deep trees are not discarded.
352
- await sleep(40);
353
- raw = await adapter.uiSnapshot(effective);
354
- value = resultValue(raw);
355
- if (isEmptySnapshot(value) && Number.isFinite(requestedDepth) && requestedDepth > 12) {
357
+ if (process.platform === 'linux' && isEmptySnapshot(value) && canRetry()) {
358
+ // GNOME/AT-SPI can transiently report an empty tree, but retries must
359
+ // never start after the caller's remaining wait budget is exhausted.
360
+ await sleep(deadline === null ? 40 : Math.min(40, Math.max(0, deadline - Date.now() - 100)));
361
+ if (canRetry()) {
362
+ raw = await capture();
363
+ value = resultValue(raw);
364
+ }
365
+ if (isEmptySnapshot(value) && Number.isFinite(requestedDepth) && requestedDepth > 12 && canRetry()) {
356
366
  effective = { ...effective, max_depth:12 };
357
- raw = await adapter.uiSnapshot(effective);
367
+ raw = await capture();
358
368
  value = resultValue(raw);
359
369
  }
360
370
  }
@@ -265,7 +265,7 @@ while queue and len(out)<${maxNodes}:
265
265
  for i in range(e.childCount):queue.append((e.getChildAtIndex(i),d+1,app_name,pid,active,window_name,node_path+"."+str(i)))
266
266
  except:pass
267
267
  print(json.dumps(out))`;
268
- const result = await runFile('python3', ['-c', script], { label: 'AT-SPI snapshot', timeout: 30_000, allowFailure: true });
268
+ const result = await runFile('python3', ['-c', script], { label: 'AT-SPI snapshot', timeout:clamp(args.timeout_ms, 30_000, 100, 30_000), allowFailure:true });
269
269
  let parsed = null;
270
270
  try { parsed = JSON.parse(result.stdout || ''); } catch {}
271
271
  if (Array.isArray(parsed)) return jsonResult({ platform: 'linux', count: parsed.length, nodes: parsed });
@@ -52,6 +52,7 @@ export async function uiMatches(args = {}) {
52
52
  ...(resolved.app ? { app: resolved.app } : {}),
53
53
  ...((resolved.window_title || resolved.windowTitle) ? { window_title: resolved.window_title || resolved.windowTitle } : {}),
54
54
  ...(resolved.browser_dom === true ? { browser_dom:true } : {}),
55
+ ...(resolved.timeout_ms != null ? { timeout_ms:resolved.timeout_ms } : {}),
55
56
  });
56
57
  const payload = resultValue(result) || {};
57
58
  const nodes = Array.isArray(payload) ? payload : payload.nodes || [];
@@ -108,6 +109,7 @@ export async function uiConditionMatches(args, condition) {
108
109
  ...(args.app ? { app:args.app } : {}),
109
110
  ...((args.window_title || args.windowTitle) ? { window_title:args.window_title || args.windowTitle } : {}),
110
111
  ...(args.browser_dom === true ? { browser_dom:true } : {}),
112
+ ...(args.timeout_ms != null ? { timeout_ms:args.timeout_ms } : {}),
111
113
  })) || {};
112
114
  nodes = Array.isArray(snapshot) ? snapshot : snapshot.nodes || [];
113
115
  }
@@ -142,8 +144,27 @@ export async function waitForUi(args = {}) {
142
144
  if (['element_exists','element_enabled'].includes(condition) && !hasSelector) throw new Error(`wait_for_ui condition=${condition} requires a semantic target`);
143
145
  if (condition === 'active_window' && !(args.text || args.name || args.window_title || args.windowTitle)) throw new Error('wait_for_ui condition=active_window requires text, name, or window_title');
144
146
  if (['present','absent'].includes(state) && !hasSelector) throw new Error(`wait_for_ui state=${state} requires a semantic target`);
145
- const deadline = Date.now() + timeoutMs;
146
147
  const startedAt = Date.now();
148
+ const deadline = startedAt + timeoutMs;
149
+ const expired = Symbol('wait_for_ui deadline');
150
+ // Resolve snapshot labels once, before invalidation; subsequent reads use the
151
+ // stable semantic locator rather than a label from a discarded tree.
152
+ const target = args.label != null ? { ...withResolvedUiLabel(args), label:undefined } : args;
153
+ const probe = async read => {
154
+ const remaining = deadline - Date.now();
155
+ if (remaining <= 0) return expired;
156
+ try {
157
+ const value = await read({ ...target, timeout_ms:Math.max(100, remaining) });
158
+ return Date.now() >= deadline ? expired : value;
159
+ } catch (error) {
160
+ if (Date.now() >= deadline) return expired;
161
+ throw error;
162
+ }
163
+ };
164
+ const pause = async () => {
165
+ const remaining = deadline - Date.now();
166
+ if (remaining > 0) await sleep(Math.min(pollMs, remaining));
167
+ };
147
168
  let attempts = 0;
148
169
 
149
170
  if (condition) {
@@ -152,7 +173,9 @@ export async function waitForUi(args = {}) {
152
173
  attempts += 1;
153
174
  // A wait must observe fresh state, not repeatedly query the same 5-second cache.
154
175
  invalidateUiSnapshotCache();
155
- evaluation = await uiConditionMatches(args, condition);
176
+ const observed = await probe(options => uiConditionMatches(options, condition));
177
+ if (observed === expired) break;
178
+ evaluation = observed;
156
179
  if (evaluation.matched) {
157
180
  return jsonResult({
158
181
  condition,
@@ -164,7 +187,7 @@ export async function waitForUi(args = {}) {
164
187
  ...(evaluation.detail ? { detail:evaluation.detail } : {}),
165
188
  });
166
189
  }
167
- await sleep(pollMs);
190
+ await pause();
168
191
  } while (Date.now() < deadline);
169
192
  return jsonResult({
170
193
  condition,
@@ -178,13 +201,20 @@ export async function waitForUi(args = {}) {
178
201
  }
179
202
 
180
203
  invalidateUiSnapshotCache();
181
- const baseline = state === 'changed' ? await uiMatches(args) : [];
204
+ const first = state === 'changed' ? await probe(uiMatches) : [];
205
+ const baseline = first === expired ? [] : first;
206
+ if (first === expired) {
207
+ return jsonResult({ state:'timeout', wanted:state, matched:0, attempts,
208
+ elapsed_ms:Date.now() - startedAt, nodes:[], before:[] });
209
+ }
182
210
  const baselineFingerprint = state === 'changed' ? uiFingerprint(baseline) : null;
183
211
  let nodes = baseline;
184
212
  do {
185
213
  attempts += 1;
186
214
  invalidateUiSnapshotCache();
187
- nodes = await uiMatches(args);
215
+ const observed = await probe(uiMatches);
216
+ if (observed === expired) break;
217
+ nodes = observed;
188
218
  const changed = state === 'changed' && uiFingerprint(nodes) !== baselineFingerprint;
189
219
  if ((state === 'present' && nodes.length) || (state === 'absent' && !nodes.length) || changed) {
190
220
  return jsonResult({
@@ -196,7 +226,7 @@ export async function waitForUi(args = {}) {
196
226
  ...(state === 'changed' ? { before: baseline } : {}),
197
227
  });
198
228
  }
199
- await sleep(pollMs);
229
+ await pause();
200
230
  } while (Date.now() < deadline);
201
231
  return jsonResult({ state:'timeout', wanted:state, matched:nodes.length, attempts, elapsed_ms:Date.now() - startedAt, nodes, ...(state === 'changed' ? { before:baseline } : {}) });
202
232
  }