@remcp/runtime 0.2.226 → 0.2.228
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 +1 -1
- package/src/extended/catalog.mjs +1 -1
- package/src/extended/desktop-core.mjs +19 -9
- package/src/extended/desktop-linux-ui.mjs +10 -5
- package/src/extended/desktop-linux-window-actions.mjs +1 -1
- package/src/extended/desktop-ui-actions.mjs +36 -6
- package/src/extended/diagnostics-recording.mjs +26 -5
package/package.json
CHANGED
package/src/extended/catalog.mjs
CHANGED
|
@@ -177,7 +177,7 @@ export const extendedToolDefinitions = [
|
|
|
177
177
|
define('environment', 'Runtime environment', 'Report platform, architecture, Node version, working/home/temp paths, shell, PATH, and optionally environment variables. Environment variables are opt-in and secret-looking or credential-bearing values are masked.', o({ include_env:b('Include sanitized environment variables; default false.') }), readOnly, diagnosticHandlers.environment),
|
|
178
178
|
define('audio', 'Audio control', 'Read or change default output volume or mute state using native operating-system audio controls when available.', o({ action:e(['status','set_volume','mute','unmute']), volume:n('0..100 for set_volume.') }, ['action']), mutatingNonDestructive, diagnosticHandlers.audio, ['audio']),
|
|
179
179
|
define('power_action', 'Power action', 'Lock, sleep, restart, or shut down the paired computer using native operating-system power facilities. The action passes through ReMCP command policy. Delayed actions are cancelled if the client stops waiting.', o({ action:e(['lock','sleep','restart','shutdown']), delay_seconds:{ type:'number', minimum:0, maximum:90, description:'Optional delay in seconds, maximum 90. Longer delays are rejected because hosted calls expire before they can complete safely.' } }, ['action']), mutating, diagnosticHandlers.power_action, ['power']),
|
|
180
|
-
define('record_screen', 'Record screen', 'Record a short bounded desktop video to a permitted local file for debugging and return its path and size. Uses native/available recording helpers.', o({ duration_seconds:n('1..120 seconds.'), fps:n('1..60.'), destination:s('Permitted output path.
|
|
180
|
+
define('record_screen', 'Record screen', 'Record a short bounded desktop video to a permitted local file for debugging and return its path and size. Uses native/available recording helpers.', o({ duration_seconds:n('1..120 seconds.'), fps:n('1..60.'), destination:s('Permitted output path. Omit the extension to accept the native format. GNOME may produce .webm or .mp4; other backends produce .mp4. An explicit extension must match the recorded format.') }), mutating, diagnosticHandlers.record_screen, ['record']),
|
|
181
181
|
|
|
182
182
|
define('read_document', 'Read document', 'Read PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, or XML. PDF/DOCX extraction is built in and XLSX is parsed from its OOXML worksheet cells.', o({ path:s('Document path.'), sheet:s('Worksheet name for XLSX.'), max_cells:n() }, ['path']), readOnly, documentHandlers.read_document, ['documents']),
|
|
183
183
|
define('edit_spreadsheet', 'Edit spreadsheet', 'Create a new XLSX workbook or edit up to 500 expanded XLSX cells directly in OOXML without launching Excel. For creation set create=true and use path as a new destination; existing files are never overwritten by create mode. Each edit may target one cell or a rectangular range, with a scalar fill, a values matrix, or a formula.', o({ path:s('Existing .xlsx path, or the new workbook destination when create=true.'), create:b('Create a new XLSX at path. Fails if path already exists; do not combine with output.'), output:s('Optional output .xlsx when editing an existing workbook; defaults to replacing input.'), sheet:s('Worksheet name. For create=true, defaults to Sheet1 and becomes the new workbook sheet name.'), edits:{ type:'array', maxItems:500, items:o({ cell:s('Single A1 cell reference.'), range:s('Rectangular range such as A1:C3.'), value:{ description:'Scalar value for one cell or to fill a range.' }, values:{ type:'array', items:{ type:'array', items:{} }, description:'2D matrix matching the range dimensions.' }, formula:s('Optional formula; for a range it is written to each expanded cell.') }) } }, ['path','edits']), mutating, documentHandlers.edit_spreadsheet, ['documents','ooxml']),
|
|
@@ -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
|
-
|
|
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
|
|
351
|
-
//
|
|
352
|
-
await sleep(40);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
367
|
+
raw = await capture();
|
|
358
368
|
value = resultValue(raw);
|
|
359
369
|
}
|
|
360
370
|
}
|
|
@@ -113,7 +113,9 @@ export async function windowAction(args) {
|
|
|
113
113
|
return text(`Window action ${action} completed.`);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
export function pyAtSpiPrelude() {
|
|
116
|
+
export function pyAtSpiPrelude({ pid = 0, app = '' } = {}) {
|
|
117
|
+
const scopedPid = Number.isInteger(Number(pid)) ? Number(pid) : 0;
|
|
118
|
+
const scopedApp = JSON.stringify(optionalString(app) || '');
|
|
117
119
|
return `import json,sys,math
|
|
118
120
|
try:
|
|
119
121
|
import pyatspi
|
|
@@ -188,10 +190,13 @@ def _proc(e):
|
|
|
188
190
|
except: pass
|
|
189
191
|
return 0
|
|
190
192
|
q=[]
|
|
193
|
+
scope_pid=${scopedPid};scope_app=${scopedApp}
|
|
191
194
|
for app in desktop:
|
|
192
195
|
try:
|
|
193
|
-
app_name=app.name or ""
|
|
194
196
|
pid=_proc(app)
|
|
197
|
+
if scope_pid and pid!=scope_pid: continue
|
|
198
|
+
app_name=app.name or ""
|
|
199
|
+
if scope_app and scope_app.lower() not in app_name.lower(): continue
|
|
195
200
|
active=False
|
|
196
201
|
try:
|
|
197
202
|
active=bool(app.getState().contains(pyatspi.STATE_ACTIVE))
|
|
@@ -216,7 +221,7 @@ export async function uiSnapshot(args = {}) {
|
|
|
216
221
|
const wantedPid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : 0;
|
|
217
222
|
const wantedWindow = JSON.stringify(optionalString(args.window_title || args.windowTitle) || '');
|
|
218
223
|
const activeOnly = args.active_only === true;
|
|
219
|
-
const script = pyAtSpiPrelude() + `
|
|
224
|
+
const script = pyAtSpiPrelude({ pid:wantedPid, app:args.app }) + `
|
|
220
225
|
want_app=${wantedApp};want_pid=${wantedPid};want_window=${wantedWindow};want_active=${activeOnly ? 'True' : 'False'}
|
|
221
226
|
roots=[]
|
|
222
227
|
for root,app_name,pid,active in q:
|
|
@@ -265,7 +270,7 @@ while queue and len(out)<${maxNodes}:
|
|
|
265
270
|
for i in range(e.childCount):queue.append((e.getChildAtIndex(i),d+1,app_name,pid,active,window_name,node_path+"."+str(i)))
|
|
266
271
|
except:pass
|
|
267
272
|
print(json.dumps(out))`;
|
|
268
|
-
const result = await runFile('python3', ['-c', script], { label: 'AT-SPI snapshot', timeout: 30_000, allowFailure:
|
|
273
|
+
const result = await runFile('python3', ['-c', script], { label: 'AT-SPI snapshot', timeout:clamp(args.timeout_ms, 30_000, 100, 30_000), allowFailure:true });
|
|
269
274
|
let parsed = null;
|
|
270
275
|
try { parsed = JSON.parse(result.stdout || ''); } catch {}
|
|
271
276
|
if (Array.isArray(parsed)) return jsonResult({ platform: 'linux', count: parsed.length, nodes: parsed });
|
|
@@ -288,7 +293,7 @@ export async function uiAction(args = {}) {
|
|
|
288
293
|
const wantedName = JSON.stringify(optionalString(args.name) || '');
|
|
289
294
|
const wantedRole = JSON.stringify(optionalString(args.role) || '');
|
|
290
295
|
const value = JSON.stringify(String(args.value ?? ''));
|
|
291
|
-
const script = pyAtSpiPrelude() + `
|
|
296
|
+
const script = pyAtSpiPrelude({ pid:wantedPid, app:args.app }) + `
|
|
292
297
|
want_index=${legacyIndex};want_pid=${wantedPid};want_path=${JSON.stringify(stablePath)}
|
|
293
298
|
want_app=${wantedApp};want_window=${wantedWindow};want_name=${wantedName};want_role=${wantedRole};action=${JSON.stringify(action)};value=${value}
|
|
294
299
|
queue=[];seen=0;target=None;target_meta={}
|
|
@@ -67,7 +67,7 @@ export async function nativeWaylandAccessibilityAction(row, actionNames) {
|
|
|
67
67
|
if (!Number.isInteger(pid) || pid <= 0 || !Array.isArray(actionNames) || !actionNames.length) {
|
|
68
68
|
return { applied:false, available:[] };
|
|
69
69
|
}
|
|
70
|
-
const script = pyAtSpiPrelude() + `
|
|
70
|
+
const script = pyAtSpiPrelude({ pid }) + `
|
|
71
71
|
want_pid=${pid};want_window=${JSON.stringify(String(row.title || ''))};wanted_actions=${JSON.stringify(actionNames)}
|
|
72
72
|
target=None
|
|
73
73
|
for root,app_name,pid,active in q:
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -253,11 +253,26 @@ print(JSON.stringify({ destination:started[1], stopped:stopped[0] }));
|
|
|
253
253
|
async function recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps }) {
|
|
254
254
|
const helper = path.join(stagingDirectory, 'gnome-screencast.js');
|
|
255
255
|
await writeFile(helper, GNOME_SCREENCAST_HELPER, { mode:0o600 });
|
|
256
|
-
await runFile('gjs', [helper, staged, String(seconds), String(fps)], {
|
|
256
|
+
const result = await runFile('gjs', [helper, staged, String(seconds), String(fps)], {
|
|
257
257
|
label:'GNOME Shell screen recording',
|
|
258
258
|
timeout:(seconds + 20) * 1000,
|
|
259
259
|
maxBuffer:2 * 1024 * 1024,
|
|
260
260
|
});
|
|
261
|
+
let report;
|
|
262
|
+
try { report = JSON.parse(result.stdout.trim()); } catch {}
|
|
263
|
+
const output = report?.destination;
|
|
264
|
+
// GNOME selects the container and may replace the template extension. Accept
|
|
265
|
+
// only the exact recording stem in our private staging directory, never an
|
|
266
|
+
// arbitrary path returned by the desktop service.
|
|
267
|
+
if (report?.stopped !== true || typeof output !== 'string' || !path.isAbsolute(output)
|
|
268
|
+
|| path.dirname(output) !== stagingDirectory
|
|
269
|
+
|| path.parse(output).name !== path.parse(staged).name
|
|
270
|
+
|| !['.mp4','.webm'].includes(path.extname(output))) {
|
|
271
|
+
throw new Error('GNOME returned an invalid screen recording staging path or format');
|
|
272
|
+
}
|
|
273
|
+
const info = await lstat(output);
|
|
274
|
+
if (!info.isFile() || !info.size) throw new Error('GNOME screen recording is not a non-empty regular file');
|
|
275
|
+
return { path:output, format:path.extname(output).slice(1) };
|
|
261
276
|
}
|
|
262
277
|
|
|
263
278
|
export function validateRecordingDestinationFormat(destination, format) {
|
|
@@ -328,21 +343,27 @@ export async function recordScreen(args) {
|
|
|
328
343
|
unavailable('Screen recording', 'ffmpeg is required on X11');
|
|
329
344
|
}
|
|
330
345
|
|
|
331
|
-
|
|
346
|
+
let format = backend === 'gnome-shell' ? 'webm' : 'mp4';
|
|
332
347
|
const requestedDestination = args.destination ? await resolveSafePath(args.destination, 'destination') : '';
|
|
333
348
|
if (requestedDestination) {
|
|
334
|
-
validateRecordingDestinationFormat(requestedDestination, format);
|
|
349
|
+
if (backend !== 'gnome-shell') validateRecordingDestinationFormat(requestedDestination, format);
|
|
350
|
+
else if (path.extname(requestedDestination) && !['.mp4','.webm'].includes(path.extname(requestedDestination).toLowerCase())) {
|
|
351
|
+
throw new Error('GNOME screen recording destination must use .mp4, .webm or omit the extension');
|
|
352
|
+
}
|
|
335
353
|
const existing = await lstat(requestedDestination).catch(() => null);
|
|
336
354
|
if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
|
|
337
355
|
if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
|
|
338
356
|
}
|
|
339
357
|
|
|
340
358
|
const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-screen-'));
|
|
341
|
-
|
|
359
|
+
let staged = path.join(stagingDirectory, `capture-${randomUUID()}.${format}`);
|
|
342
360
|
let keepStaging = false;
|
|
343
361
|
try {
|
|
344
362
|
if (backend === 'gnome-shell') {
|
|
345
|
-
await recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps });
|
|
363
|
+
const recorded = await recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps });
|
|
364
|
+
staged = recorded.path;
|
|
365
|
+
format = recorded.format;
|
|
366
|
+
validateRecordingDestinationFormat(requestedDestination, format);
|
|
346
367
|
} else if (backend === 'wf-recorder') {
|
|
347
368
|
const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',staged,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
|
|
348
369
|
if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
|