getobsrv 0.3.0 → 0.4.0
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/README.md +30 -8
- package/bin/obsrv.js +8 -0
- package/out/main/cli.js +1 -1
- package/out/main/index.js +292 -7
- package/out/main/{targetSource-w_vWw7zd.js → targetSource-DkXWE0ha.js} +12 -1
- package/out/mcp/control.js +105 -0
- package/out/mcp/lib.js +45 -24
- package/out/mcp/server.js +215 -10
- package/out/preload/app.js +14 -2
- package/out/renderer/assets/{index-VleEBVgL.css → index-FtoKShe_.css} +15 -1
- package/out/renderer/assets/{index-BAEO9_6W.js → index-P496vEhv.js} +49 -1
- package/out/renderer/index.html +2 -2
- package/out/shared/control.js +153 -0
- package/out/shared/presets.js +1 -1
- package/out/shared/url.js +54 -0
- package/package.json +8 -5
- package/skills/obsrv-screens/SKILL.md +17 -11
package/out/mcp/lib.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
|
|
3
|
+
exports.APP_NOT_REACHABLE = exports.urlSchemeError = exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
|
|
4
4
|
exports.buildSnapArgs = buildSnapArgs;
|
|
5
5
|
exports.buildDiffArgs = buildDiffArgs;
|
|
6
6
|
exports.shouldInlineImage = shouldInlineImage;
|
|
7
7
|
exports.killBudgetMs = killBudgetMs;
|
|
8
8
|
exports.stderrTail = stderrTail;
|
|
9
|
-
exports.
|
|
9
|
+
exports.planSnapPath = planSnapPath;
|
|
10
10
|
exports.extractTrailingJson = extractTrailingJson;
|
|
11
11
|
exports.listCatalog = listCatalog;
|
|
12
12
|
const presets_1 = require("../shared/presets");
|
|
@@ -97,30 +97,51 @@ function stderrTail(stderr, max = exports.STDERR_TAIL_CHARS) {
|
|
|
97
97
|
const trimmed = stderr.trim();
|
|
98
98
|
return trimmed.length <= max ? trimmed : `…${trimmed.slice(-max)}`;
|
|
99
99
|
}
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
// The scheme allowlist lives in shared/url.ts so the agent-control server
|
|
101
|
+
// applies the identical check; re-exported so existing importers (and their
|
|
102
|
+
// unit tests) keep their path.
|
|
103
|
+
var url_1 = require("../shared/url");
|
|
104
|
+
Object.defineProperty(exports, "ALLOWED_URL_SCHEMES", { enumerable: true, get: function () { return url_1.ALLOWED_URL_SCHEMES; } });
|
|
105
|
+
Object.defineProperty(exports, "urlSchemeError", { enumerable: true, get: function () { return url_1.urlSchemeError; } });
|
|
106
|
+
exports.APP_NOT_REACHABLE = 'The Obsrv app is not reachable. Open the Obsrv desktop app and enable "Agent control" in the toolbar ' +
|
|
107
|
+
'(or pass mode: "headless" to render without it).';
|
|
102
108
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
109
|
+
* Decides whether an `obsrv_snap` call drives the visible app or renders
|
|
110
|
+
* headlessly (spec §14 "Live drive"), given whether a control-enabled app
|
|
111
|
+
* answered discovery. Documented calls, exercised in tests/unit/mcpLib.test.ts:
|
|
112
|
+
*
|
|
113
|
+
* - custom dims (width/height/dsf/diagonal) always render headlessly — the
|
|
114
|
+
* live path drives the app's preset table only — with a note, even under
|
|
115
|
+
* an explicit `mode: 'live'`.
|
|
116
|
+
* - `fullPage` always renders headlessly (the visible window cannot show a
|
|
117
|
+
* full page), with a note.
|
|
118
|
+
* - `waitMs` is honoured headlessly; on the live path it is ignored with a
|
|
119
|
+
* note (the live capture settles on the app's own committed navigation).
|
|
120
|
+
* - `mode: 'live'` with no reachable app is an error, never a silent
|
|
121
|
+
* headless fallback — the caller asked to watch.
|
|
108
122
|
*/
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (!
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
function planSnapPath(input, mode, liveReachable) {
|
|
124
|
+
if (mode === 'headless')
|
|
125
|
+
return { path: 'headless', notes: [] };
|
|
126
|
+
if (!liveReachable) {
|
|
127
|
+
if (mode === 'live')
|
|
128
|
+
return { error: exports.APP_NOT_REACHABLE };
|
|
129
|
+
return { path: 'headless', notes: [] };
|
|
130
|
+
}
|
|
131
|
+
const notes = [];
|
|
132
|
+
const custom = input.width !== undefined ||
|
|
133
|
+
input.height !== undefined ||
|
|
134
|
+
input.deviceScaleFactor !== undefined ||
|
|
135
|
+
input.diagonalInches !== undefined;
|
|
136
|
+
if (custom)
|
|
137
|
+
notes.push('custom dimensions are headless-only (live mode drives the preset table); rendered headlessly.');
|
|
138
|
+
if (input.fullPage)
|
|
139
|
+
notes.push('fullPage is headless-only; rendered headlessly instead of driving the app.');
|
|
140
|
+
if (notes.length > 0)
|
|
141
|
+
return { path: 'headless', notes };
|
|
142
|
+
if (input.waitMs !== undefined)
|
|
143
|
+
notes.push('waitMs is headless-only and was ignored in live mode.');
|
|
144
|
+
return { path: 'live', notes };
|
|
124
145
|
}
|
|
125
146
|
/**
|
|
126
147
|
* Parses the CLI's machine output: the trailing JSON object on stdout.
|
package/out/mcp/server.js
CHANGED
|
@@ -9,7 +9,9 @@ const node_os_1 = require("node:os");
|
|
|
9
9
|
const node_path_1 = require("node:path");
|
|
10
10
|
const zod_1 = require("zod");
|
|
11
11
|
const args_1 = require("../cli/args");
|
|
12
|
+
const control_1 = require("../shared/control");
|
|
12
13
|
const presets_1 = require("../shared/presets");
|
|
14
|
+
const control_2 = require("./control");
|
|
13
15
|
const lib_1 = require("./lib");
|
|
14
16
|
/**
|
|
15
17
|
* Obsrv MCP server (stdio, stateless): three read-only tools wrapping the
|
|
@@ -116,17 +118,33 @@ const snapInputShape = {
|
|
|
116
118
|
.min(1)
|
|
117
119
|
.optional()
|
|
118
120
|
.describe(`Per-render budget for load + paint quiescence, in ms. Default ${args_1.DEFAULT_TIMEOUT_MS}.`),
|
|
121
|
+
mode: zod_1.z
|
|
122
|
+
.enum(['auto', 'headless', 'live'])
|
|
123
|
+
.optional()
|
|
124
|
+
.describe('auto (default): drive the visible Obsrv app when it is open with Agent control on, else render headlessly. ' +
|
|
125
|
+
'live: require the app (error if unreachable). headless: never touch the app.'),
|
|
119
126
|
};
|
|
120
127
|
const snapOutputShape = {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
mode: zod_1.z
|
|
129
|
+
.enum(['headless', 'live'])
|
|
130
|
+
.describe('How the snap was produced: a headless render, or a capture of the visible Obsrv app window (live drive).'),
|
|
131
|
+
out: zod_1.z.string().optional().describe('Headless only: PNG path the CLI wrote (same file as pngPath).'),
|
|
132
|
+
preset: zod_1.z.string().optional().describe('Headless only: preset id, or "custom" for width/height runs.'),
|
|
133
|
+
cssWidth: zod_1.z.number().optional().describe('Headless only: applied CSS viewport width.'),
|
|
134
|
+
cssHeight: zod_1.z.number().optional().describe('Headless only: applied CSS viewport height (grown under fullPage).'),
|
|
135
|
+
deviceScaleFactor: zod_1.z.number().optional().describe('Headless only.'),
|
|
136
|
+
profile: zod_1.z.string().optional().describe('Headless only: applied panel profile id.'),
|
|
137
|
+
settled: zod_1.z
|
|
138
|
+
.boolean()
|
|
139
|
+
.describe('Headless: the page went paint-quiet. Live: the app confirmed the navigation before the capture.'),
|
|
128
140
|
warnings: zod_1.z.array(zod_1.z.string()),
|
|
129
141
|
pngPath: zod_1.z.string().describe('Absolute path of the captured PNG (kept in a per-call temp dir).'),
|
|
142
|
+
url: zod_1.z.string().optional().describe('Live only: the URL the app reports showing.'),
|
|
143
|
+
presetId: zod_1.z.string().optional().describe('Live only: the screen preset selected in the app.'),
|
|
144
|
+
profileId: zod_1.z.string().optional().describe('Live only: the panel profile selected in the app.'),
|
|
145
|
+
viewMode: zod_1.z.string().optional().describe("Live only: the app's target-pane view (1:1 or fit)."),
|
|
146
|
+
width: zod_1.z.number().optional().describe('Live only: captured app-window width in px.'),
|
|
147
|
+
height: zod_1.z.number().optional().describe('Live only: captured app-window height in px.'),
|
|
130
148
|
};
|
|
131
149
|
const diffInputShape = {
|
|
132
150
|
url: urlField,
|
|
@@ -187,6 +205,122 @@ const presetsOutputShape = {
|
|
|
187
205
|
summary: zod_1.z.string(),
|
|
188
206
|
})),
|
|
189
207
|
};
|
|
208
|
+
const driveInputShape = {
|
|
209
|
+
url: zod_1.z
|
|
210
|
+
.string()
|
|
211
|
+
.min(1)
|
|
212
|
+
.optional()
|
|
213
|
+
.describe('Navigate the app (both panes) to this http://, https:// or file:// URL (bare hosts also work).'),
|
|
214
|
+
preset: zod_1.z.enum(PRESET_IDS).optional().describe('Apply this screen preset, exactly as clicking the toolbar would.'),
|
|
215
|
+
profile: zod_1.z.enum(PROFILE_IDS).optional().describe('Apply this panel profile in the app.'),
|
|
216
|
+
viewMode: zod_1.z.enum(['1:1', 'fit']).optional().describe("Switch the app's target pane between 1:1 (actual size) and fit."),
|
|
217
|
+
};
|
|
218
|
+
const driveOutputShape = {
|
|
219
|
+
version: zod_1.z.string().describe('The running app version.'),
|
|
220
|
+
url: zod_1.z.string().describe('The URL the target pane reports showing.'),
|
|
221
|
+
presetId: zod_1.z.string(),
|
|
222
|
+
profileId: zod_1.z.string(),
|
|
223
|
+
viewMode: zod_1.z.string(),
|
|
224
|
+
mode: zod_1.z.string().describe("The app's pane mode: 'url' (live page) or 'image' (a dropped design export)."),
|
|
225
|
+
};
|
|
226
|
+
// --- live drive --------------------------------------------------------------
|
|
227
|
+
/** Budget for one control `status` round-trip once the app is known live. */
|
|
228
|
+
const LIVE_STATUS_TIMEOUT_MS = 2_000;
|
|
229
|
+
/** Budget for a preset/profile/view-mode apply (the server confirms, bounded). */
|
|
230
|
+
const LIVE_APPLY_TIMEOUT_MS = 5_000;
|
|
231
|
+
/** Budget for `captureVisible` (a full-window PNG over loopback). */
|
|
232
|
+
const LIVE_CAPTURE_TIMEOUT_MS = 30_000;
|
|
233
|
+
/** How long a live snap waits for `status.url` to reflect the navigation. */
|
|
234
|
+
const LIVE_SETTLE_MS = 5_000;
|
|
235
|
+
function liveFailure(e) {
|
|
236
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
237
|
+
return (`${msg}. If the Obsrv app was closed or Agent control was toggled off mid-call, ` +
|
|
238
|
+
`re-open the app and re-enable the toolbar toggle — or pass mode: "headless".`);
|
|
239
|
+
}
|
|
240
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
241
|
+
/**
|
|
242
|
+
* The live `obsrv_snap` path: navigate the visible app (plus preset/profile
|
|
243
|
+
* when given), wait — bounded — for the app to report the navigation, then
|
|
244
|
+
* capture the window exactly as the user sees it.
|
|
245
|
+
*/
|
|
246
|
+
async function liveSnap(app, input, notes) {
|
|
247
|
+
const { info } = app;
|
|
248
|
+
const warnings = [...notes];
|
|
249
|
+
const before = app.status.url;
|
|
250
|
+
let applied = '';
|
|
251
|
+
try {
|
|
252
|
+
// The navigate command answers once both panes finished loading, so it
|
|
253
|
+
// carries the same per-render budget the headless path polices.
|
|
254
|
+
const nav = await (0, control_2.controlCall)(info, 'navigate', { url: input.url.trim() }, (input.timeoutMs ?? args_1.DEFAULT_TIMEOUT_MS) + 10_000);
|
|
255
|
+
applied = typeof nav['url'] === 'string' ? nav['url'] : '';
|
|
256
|
+
if (input.preset !== undefined)
|
|
257
|
+
await (0, control_2.controlCall)(info, 'setPreset', { id: input.preset }, LIVE_APPLY_TIMEOUT_MS);
|
|
258
|
+
if (input.profile !== undefined)
|
|
259
|
+
await (0, control_2.controlCall)(info, 'setProfile', { id: input.profile }, LIVE_APPLY_TIMEOUT_MS);
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
return toolError(liveFailure(e));
|
|
263
|
+
}
|
|
264
|
+
// The app settles when it reports the applied URL — or, after a redirect,
|
|
265
|
+
// any committed non-blank URL that is no longer the pre-navigation one.
|
|
266
|
+
let status = app.status;
|
|
267
|
+
let settled = false;
|
|
268
|
+
const deadline = Date.now() + LIVE_SETTLE_MS;
|
|
269
|
+
for (;;) {
|
|
270
|
+
try {
|
|
271
|
+
const s = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
272
|
+
if (s) {
|
|
273
|
+
status = s;
|
|
274
|
+
settled = s.url === applied || (applied !== '' && s.url !== before && s.url !== 'about:blank');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
return toolError(liveFailure(e));
|
|
279
|
+
}
|
|
280
|
+
if (settled || Date.now() >= deadline)
|
|
281
|
+
break;
|
|
282
|
+
await sleep(250);
|
|
283
|
+
}
|
|
284
|
+
if (!settled)
|
|
285
|
+
warnings.push('the app did not confirm the navigation before capture; the PNG may show the previous page.');
|
|
286
|
+
// One short grace after the state settles: the renderer repaints the pane
|
|
287
|
+
// (and any preset resize) a frame or two after the store confirms, and a
|
|
288
|
+
// capture racing that would show a half-applied flip.
|
|
289
|
+
await sleep(300);
|
|
290
|
+
let capture;
|
|
291
|
+
try {
|
|
292
|
+
capture = await (0, control_2.controlCall)(info, 'captureVisible', {}, LIVE_CAPTURE_TIMEOUT_MS);
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
return toolError(liveFailure(e));
|
|
296
|
+
}
|
|
297
|
+
const { data, width, height } = capture;
|
|
298
|
+
if (typeof data !== 'string' || typeof width !== 'number' || typeof height !== 'number') {
|
|
299
|
+
return toolError('the control server returned a malformed capture');
|
|
300
|
+
}
|
|
301
|
+
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
302
|
+
const pngPath = (0, node_path_1.join)(dir, 'live.png');
|
|
303
|
+
await (0, promises_1.writeFile)(pngPath, Buffer.from(data, 'base64'));
|
|
304
|
+
const structured = {
|
|
305
|
+
mode: 'live',
|
|
306
|
+
url: status.url,
|
|
307
|
+
presetId: status.presetId,
|
|
308
|
+
profileId: status.profileId,
|
|
309
|
+
viewMode: status.viewMode,
|
|
310
|
+
width,
|
|
311
|
+
height,
|
|
312
|
+
settled,
|
|
313
|
+
warnings,
|
|
314
|
+
pngPath,
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
content: [
|
|
318
|
+
{ type: 'text', text: JSON.stringify(structured, null, 2) },
|
|
319
|
+
await imageOrNote(pngPath, 'The captured app window', 'read the file at pngPath'),
|
|
320
|
+
],
|
|
321
|
+
structuredContent: structured,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
190
324
|
// --- server ------------------------------------------------------------------
|
|
191
325
|
const server = new mcp_js_1.McpServer({ name: 'obsrv-mcp-server', version: VERSION });
|
|
192
326
|
server.registerTool('obsrv_snap', {
|
|
@@ -198,7 +332,15 @@ server.registerTool('obsrv_snap', {
|
|
|
198
332
|
`Pass either \`preset\` (list ids with obsrv_presets) or custom \`width\` + \`height\`, never both. ` +
|
|
199
333
|
`Returns structured metadata (applied viewport, profile, \`settled\`, warnings, and \`pngPath\` — the PNG ` +
|
|
200
334
|
`kept in a per-call temp dir) plus the PNG as an inline image when it is within the 1.5 MiB cap; larger ` +
|
|
201
|
-
`captures (typically fullPage) stay on disk with a note
|
|
335
|
+
`captures (typically fullPage) stay on disk with a note.\n\n` +
|
|
336
|
+
`Live drive: when the Obsrv desktop app is open with its "Agent control" toolbar toggle on, \`mode: "auto"\` ` +
|
|
337
|
+
`(the default) drives the *visible* app instead — the user watches the URL load and the preset flip, and the ` +
|
|
338
|
+
`returned PNG is the app window as they see it (\`mode: "live"\` in the result; \`mode: "headless"\` ` +
|
|
339
|
+
`otherwise). Custom width/height and \`fullPage\` always render headlessly (with a note); \`waitMs\` is ` +
|
|
340
|
+
`ignored in live mode. \`mode: "live"\` errors when the app is not reachable; \`mode: "headless"\` never ` +
|
|
341
|
+
`touches it. Note: although this tool is annotated read-only (it renders and captures), a live snap steers ` +
|
|
342
|
+
`the open app window — navigating it and flipping its preset in front of the user — as its means of ` +
|
|
343
|
+
`capture; that visible steering is the point of live mode.`,
|
|
202
344
|
inputSchema: snapInputShape,
|
|
203
345
|
outputSchema: snapOutputShape,
|
|
204
346
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -206,6 +348,20 @@ server.registerTool('obsrv_snap', {
|
|
|
206
348
|
const badScheme = (0, lib_1.urlSchemeError)(input.url);
|
|
207
349
|
if (badScheme)
|
|
208
350
|
return toolError(badScheme);
|
|
351
|
+
// The live path first (spec §14 "Live drive"): a reachable control-enabled
|
|
352
|
+
// app wins under auto, is required under live, and is never probed under
|
|
353
|
+
// headless. planSnapPath documents the fallback rules.
|
|
354
|
+
const requestedMode = input.mode ?? 'auto';
|
|
355
|
+
let liveNotes = [];
|
|
356
|
+
if (requestedMode !== 'headless') {
|
|
357
|
+
const live = await (0, control_2.discoverControl)();
|
|
358
|
+
const plan = (0, lib_1.planSnapPath)(input, requestedMode, live !== null);
|
|
359
|
+
if ('error' in plan)
|
|
360
|
+
return toolError(plan.error);
|
|
361
|
+
if (plan.path === 'live' && live)
|
|
362
|
+
return liveSnap(live, input, plan.notes);
|
|
363
|
+
liveNotes = plan.notes;
|
|
364
|
+
}
|
|
209
365
|
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
210
366
|
const pngPath = (0, node_path_1.join)(dir, 'snap.png');
|
|
211
367
|
let args;
|
|
@@ -225,7 +381,8 @@ server.registerTool('obsrv_snap', {
|
|
|
225
381
|
const meta = (0, lib_1.extractTrailingJson)(run.stdout);
|
|
226
382
|
if (!meta)
|
|
227
383
|
return toolError(`obsrv snap exited 0 but printed unparseable JSON: ${(0, lib_1.stderrTail)(run.stdout)}`);
|
|
228
|
-
const
|
|
384
|
+
const cliWarnings = Array.isArray(meta['warnings']) ? meta['warnings'] : [];
|
|
385
|
+
const structured = { ...meta, mode: 'headless', warnings: [...cliWarnings, ...liveNotes], pngPath };
|
|
229
386
|
return {
|
|
230
387
|
content: [
|
|
231
388
|
{ type: 'text', text: JSON.stringify(structured, null, 2) },
|
|
@@ -245,7 +402,10 @@ server.registerTool('obsrv_diff', {
|
|
|
245
402
|
`paths of target.png / reference.png in a per-call temp dir. \`includeImages: true\` also inlines both ` +
|
|
246
403
|
`PNGs (1.5 MiB cap each).\n\n` +
|
|
247
404
|
`1x presets only (e.g. laptop-768, 1080p-24): dense presets (phones) and CSS viewports over 2048px are ` +
|
|
248
|
-
`refused with an explanatory error — use obsrv_snap for those
|
|
405
|
+
`refused with an explanatory error — use obsrv_snap for those.\n\n` +
|
|
406
|
+
`Headless-only: a diff always performs its own two renders and never drives a running Obsrv app window ` +
|
|
407
|
+
`(the comparison needs both rasters, which the visible app cannot show) — use obsrv_snap or obsrv_drive ` +
|
|
408
|
+
`for live drive.`,
|
|
249
409
|
inputSchema: diffInputShape,
|
|
250
410
|
outputSchema: diffOutputShape,
|
|
251
411
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -273,6 +433,51 @@ server.registerTool('obsrv_diff', {
|
|
|
273
433
|
}
|
|
274
434
|
return { content, structuredContent: metrics };
|
|
275
435
|
});
|
|
436
|
+
server.registerTool('obsrv_drive', {
|
|
437
|
+
title: 'Drive the visible Obsrv app',
|
|
438
|
+
description: `Drive the Obsrv desktop app the user is looking at: navigate it to a URL, apply a screen preset, a panel ` +
|
|
439
|
+
`profile, or the target pane's 1:1/fit view — each applied exactly as clicking the toolbar would, while the ` +
|
|
440
|
+
`user watches. Applies whichever inputs are given (none = just read the current state) and returns the ` +
|
|
441
|
+
`resulting status: app version, the URL showing, and the selected preset/profile/view.\n\n` +
|
|
442
|
+
`Requires the app to be open with its "Agent control" toolbar toggle on; errors otherwise. This tool ` +
|
|
443
|
+
`mutates visible app state (it changes what the user's window shows) but renders nothing itself — use ` +
|
|
444
|
+
`obsrv_snap for a capture.`,
|
|
445
|
+
inputSchema: driveInputShape,
|
|
446
|
+
outputSchema: driveOutputShape,
|
|
447
|
+
// Honest annotation: this changes what the user's window is showing.
|
|
448
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
449
|
+
}, async (input) => {
|
|
450
|
+
if (input.url !== undefined) {
|
|
451
|
+
const badScheme = (0, lib_1.urlSchemeError)(input.url);
|
|
452
|
+
if (badScheme)
|
|
453
|
+
return toolError(badScheme);
|
|
454
|
+
}
|
|
455
|
+
const live = await (0, control_2.discoverControl)();
|
|
456
|
+
if (!live)
|
|
457
|
+
return toolError(lib_1.APP_NOT_REACHABLE);
|
|
458
|
+
try {
|
|
459
|
+
if (input.url !== undefined) {
|
|
460
|
+
await (0, control_2.controlCall)(live.info, 'navigate', { url: input.url.trim() }, args_1.DEFAULT_TIMEOUT_MS + 10_000);
|
|
461
|
+
}
|
|
462
|
+
if (input.preset !== undefined)
|
|
463
|
+
await (0, control_2.controlCall)(live.info, 'setPreset', { id: input.preset }, LIVE_APPLY_TIMEOUT_MS);
|
|
464
|
+
if (input.profile !== undefined)
|
|
465
|
+
await (0, control_2.controlCall)(live.info, 'setProfile', { id: input.profile }, LIVE_APPLY_TIMEOUT_MS);
|
|
466
|
+
if (input.viewMode !== undefined) {
|
|
467
|
+
await (0, control_2.controlCall)(live.info, 'setViewMode', { mode: input.viewMode }, LIVE_APPLY_TIMEOUT_MS);
|
|
468
|
+
}
|
|
469
|
+
const status = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
470
|
+
if (!status)
|
|
471
|
+
return toolError('the control server returned a malformed status');
|
|
472
|
+
return {
|
|
473
|
+
content: [{ type: 'text', text: JSON.stringify(status, null, 2) }],
|
|
474
|
+
structuredContent: { ...status },
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
catch (e) {
|
|
478
|
+
return toolError(liveFailure(e));
|
|
479
|
+
}
|
|
480
|
+
});
|
|
276
481
|
server.registerTool('obsrv_presets', {
|
|
277
482
|
title: 'List screen presets and panel profiles',
|
|
278
483
|
description: `List every screen preset (id, label, group, CSS dims, deviceScaleFactor, panel diagonal, derived physical ` +
|
package/out/preload/app.js
CHANGED
|
@@ -22,7 +22,10 @@ const IPC = {
|
|
|
22
22
|
openImage: "obsrv:open-image",
|
|
23
23
|
focusUrl: "obsrv:focus-url",
|
|
24
24
|
openImagePath: "obsrv:open-image-path",
|
|
25
|
-
readImageFile: "obsrv:read-image-file"
|
|
25
|
+
readImageFile: "obsrv:read-image-file",
|
|
26
|
+
uiState: "obsrv:ui-state",
|
|
27
|
+
agentApply: "obsrv:agent-apply",
|
|
28
|
+
agentActivity: "obsrv:agent-activity"
|
|
26
29
|
};
|
|
27
30
|
function subscribe(channel, cb) {
|
|
28
31
|
const listener = (_e, v) => cb(v);
|
|
@@ -82,6 +85,15 @@ const api = {
|
|
|
82
85
|
};
|
|
83
86
|
},
|
|
84
87
|
onOpenImagePath: (cb) => subscribe(IPC.openImagePath, cb),
|
|
85
|
-
readImageFile: (path) => electron.ipcRenderer.invoke(IPC.readImageFile, path)
|
|
88
|
+
readImageFile: (path) => electron.ipcRenderer.invoke(IPC.readImageFile, path),
|
|
89
|
+
reportUiState: (s) => electron.ipcRenderer.send(IPC.uiState, s),
|
|
90
|
+
onAgentApply: (cb) => subscribe(IPC.agentApply, cb),
|
|
91
|
+
onAgentActivity: (cb) => {
|
|
92
|
+
const listener = () => cb();
|
|
93
|
+
electron.ipcRenderer.on(IPC.agentActivity, listener);
|
|
94
|
+
return () => {
|
|
95
|
+
electron.ipcRenderer.removeListener(IPC.agentActivity, listener);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
86
98
|
};
|
|
87
99
|
electron.contextBridge.exposeInMainWorld("obsrv", api);
|
|
@@ -251,11 +251,25 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
251
251
|
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
|
252
252
|
|
|
253
253
|
/* An open drawer's button: weight and a 1px border, not hue. */
|
|
254
|
-
.toolbar :is(.toggle-panel, .toggle-settings)[aria-pressed='true'] {
|
|
254
|
+
.toolbar :is(.toggle-panel, .toggle-settings, .agent-toggle)[aria-pressed='true'] {
|
|
255
255
|
border-color: var(--text-0);
|
|
256
256
|
background: var(--chrome-1);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/* Agent control: same neutral chrome as every other toggle; the activity
|
|
260
|
+
badge is text-weight only — an agent driving the window is a status, not
|
|
261
|
+
an alert. */
|
|
262
|
+
.toolbar .agent-toggle { width: auto; padding: 0 8px; white-space: nowrap; }
|
|
263
|
+
.agent-activity {
|
|
264
|
+
color: var(--text-1);
|
|
265
|
+
border: 1px solid var(--line);
|
|
266
|
+
border-radius: 4px;
|
|
267
|
+
padding: 1px 6px;
|
|
268
|
+
font-size: 11px;
|
|
269
|
+
letter-spacing: 0.5px;
|
|
270
|
+
white-space: nowrap;
|
|
271
|
+
}
|
|
272
|
+
|
|
259
273
|
/* Drawers sit beside the panes, never over them: the native WebContentsView is
|
|
260
274
|
an OS-level overlay and would cover anything painted on top of it. */
|
|
261
275
|
.body { flex: 1 1 auto; display: flex; min-height: 0; }
|
|
@@ -12688,7 +12688,7 @@ const createImpl = (createState) => {
|
|
|
12688
12688
|
};
|
|
12689
12689
|
const create = ((createState) => createImpl);
|
|
12690
12690
|
const MAX_VIEWPORT = 4096;
|
|
12691
|
-
const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500 };
|
|
12691
|
+
const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500, agentControl: false };
|
|
12692
12692
|
const SCREEN_PRESETS = [
|
|
12693
12693
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
12694
12694
|
{ id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
|
|
@@ -14172,8 +14172,29 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
|
|
|
14172
14172
|
const setSurround = useStore((s) => s.setSurround);
|
|
14173
14173
|
const viewMode = useStore((s) => s.viewMode);
|
|
14174
14174
|
const setViewMode = useStore((s) => s.setViewMode);
|
|
14175
|
+
const agentControl = useStore((s) => s.settings.agentControl);
|
|
14176
|
+
const setSettings = useStore((s) => s.setSettings);
|
|
14175
14177
|
const inputRef = reactExports.useRef(null);
|
|
14176
14178
|
const [draft, setDraft] = reactExports.useState(barText);
|
|
14179
|
+
const [agentActive, setAgentActive] = reactExports.useState(false);
|
|
14180
|
+
reactExports.useEffect(() => {
|
|
14181
|
+
let timer;
|
|
14182
|
+
const off = window.obsrv.onAgentActivity(() => {
|
|
14183
|
+
setAgentActive(true);
|
|
14184
|
+
clearTimeout(timer);
|
|
14185
|
+
timer = setTimeout(() => setAgentActive(false), 3e3);
|
|
14186
|
+
});
|
|
14187
|
+
return () => {
|
|
14188
|
+
clearTimeout(timer);
|
|
14189
|
+
off();
|
|
14190
|
+
};
|
|
14191
|
+
}, []);
|
|
14192
|
+
const toggleAgent = () => {
|
|
14193
|
+
const current = useStore.getState().settings;
|
|
14194
|
+
const next = { ...current, agentControl: !current.agentControl };
|
|
14195
|
+
setSettings(next);
|
|
14196
|
+
window.obsrv.setSettings(next).catch(() => setSettings(current));
|
|
14197
|
+
};
|
|
14177
14198
|
const lastMode = reactExports.useRef(mode);
|
|
14178
14199
|
reactExports.useEffect(() => {
|
|
14179
14200
|
const modeChanged = lastMode.current !== mode;
|
|
@@ -14290,6 +14311,19 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
|
|
|
14290
14311
|
},
|
|
14291
14312
|
s.id
|
|
14292
14313
|
)) }),
|
|
14314
|
+
agentActive && /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "agent-activity", children: "AGENT" }),
|
|
14315
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
14316
|
+
"button",
|
|
14317
|
+
{
|
|
14318
|
+
className: "agent-toggle",
|
|
14319
|
+
type: "button",
|
|
14320
|
+
title: "Agent control — let a local agent drive this window",
|
|
14321
|
+
"aria-label": "Agent control",
|
|
14322
|
+
"aria-pressed": agentControl,
|
|
14323
|
+
onClick: toggleAgent,
|
|
14324
|
+
children: "Agent"
|
|
14325
|
+
}
|
|
14326
|
+
),
|
|
14293
14327
|
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
14294
14328
|
"button",
|
|
14295
14329
|
{
|
|
@@ -14334,6 +14368,9 @@ function App() {
|
|
|
14334
14368
|
const surround = useStore((s) => s.surround);
|
|
14335
14369
|
const viewport = useStore(useShallow(selectViewport));
|
|
14336
14370
|
const deviceScaleFactor = useStore(selectDeviceScaleFactor);
|
|
14371
|
+
const presetId = useStore((s) => s.presetId);
|
|
14372
|
+
const profileId = useStore((s) => s.profileId);
|
|
14373
|
+
const viewMode = useStore((s) => s.viewMode);
|
|
14337
14374
|
reactExports.useEffect(() => {
|
|
14338
14375
|
window.obsrv.getHostInfo().then(setHost, (e) => console.warn("obsrv: getHostInfo failed", e));
|
|
14339
14376
|
window.obsrv.getSettings().then(setSettings, (e) => console.warn("obsrv: getSettings failed", e));
|
|
@@ -14359,6 +14396,17 @@ function App() {
|
|
|
14359
14396
|
reactExports.useEffect(() => {
|
|
14360
14397
|
window.obsrv.setMode(mode);
|
|
14361
14398
|
}, [mode]);
|
|
14399
|
+
reactExports.useEffect(() => {
|
|
14400
|
+
window.obsrv.reportUiState({ presetId, profileId, viewMode, mode });
|
|
14401
|
+
}, [presetId, profileId, viewMode, mode]);
|
|
14402
|
+
reactExports.useEffect(() => {
|
|
14403
|
+
return window.obsrv.onAgentApply((patch) => {
|
|
14404
|
+
const s = useStore.getState();
|
|
14405
|
+
if (patch.presetId !== void 0) s.setPreset(patch.presetId);
|
|
14406
|
+
if (patch.profileId !== void 0) s.setProfile(patch.profileId);
|
|
14407
|
+
if (patch.viewMode !== void 0) s.setViewMode(patch.viewMode);
|
|
14408
|
+
});
|
|
14409
|
+
}, []);
|
|
14362
14410
|
reactExports.useEffect(() => {
|
|
14363
14411
|
document.documentElement.dataset.surround = surround;
|
|
14364
14412
|
}, [surround]);
|
package/out/renderer/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:" />
|
|
6
6
|
<title>Obsrv</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-P496vEhv.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-FtoKShe_.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|