getobsrv 0.4.1 → 0.5.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 CHANGED
@@ -83,8 +83,10 @@ raster density — the PNG comes back as an inline image up to 1.5 MiB),
83
83
  If the desktop app is open with the toolbar's **Agent control** toggle on,
84
84
  `obsrv_snap` drives the *visible* window instead: you watch the URL load and
85
85
  the preset flip, and the agent gets back a capture of the app exactly as you
86
- see it (plus `obsrv_drive` to flip URL/preset/profile directly). With no app
87
- running, everything falls back to the headless render automatically.
86
+ see it (plus `obsrv_drive` to flip URL/preset/profile directly). Agents can
87
+ also scroll, click, pan and highlight while you watch — a drive session works
88
+ as a guided demo. With no app running, everything falls back to the headless
89
+ render automatically.
88
90
 
89
91
  Build first, then register:
90
92
 
@@ -119,7 +121,16 @@ xattr -cr /Applications/Obsrv.app
119
121
 
120
122
  ## Distribution
121
123
 
122
- Obsrv will publish to npm as **`getobsrv`** (the installed commands remain `obsrv`
124
+ Publish via a packed tarball, never bare `npm publish`: `npm publish` snapshots
125
+ package.json before lifecycle hooks run, which silently skips the prepack
126
+ electron dev→prod dependency swap (this shipped a broken 0.4.0). The flow is:
127
+
128
+ ```bash
129
+ npm run release:pack
130
+ npm publish ./getobsrv-<version>.tgz
131
+ ```
132
+
133
+ Obsrv publishes to npm as **`getobsrv`** (the installed commands remain `obsrv`
123
134
  and `obsrv-mcp`; the app's display name remains Obsrv). The bare `obsrv` npm name
124
135
  belongs to an unrelated package.
125
136
 
package/out/main/index.js CHANGED
@@ -68,51 +68,11 @@ function attachFrameBus(target, win) {
68
68
  }
69
69
  };
70
70
  }
71
- const CONTROL_FILE_NAME = "control.json";
72
- const CONTROL_TOKEN_BYTES = 32;
73
- const CONTROL_COMMANDS = [
74
- "status",
75
- "navigate",
76
- "setPreset",
77
- "setProfile",
78
- "setViewMode",
79
- "captureVisible"
80
- ];
81
- function isControlCommand(v) {
82
- return typeof v === "string" && CONTROL_COMMANDS.includes(v);
83
- }
84
- function tokenEqual(expected, provided) {
85
- if (typeof provided !== "string") return false;
86
- const a = node_crypto.createHash("sha256").update(expected).digest();
87
- const b = node_crypto.createHash("sha256").update(provided).digest();
88
- return node_crypto.timingSafeEqual(a, b);
89
- }
90
- const idList = (ids) => ids.join(", ");
91
- function presetApplyError(id) {
92
- if (typeof id !== "string") return "setPreset payload must be { id: string }";
93
- if (id === "custom") {
94
- return "the custom preset cannot be applied remotely — it is defined by the fields in the app; pick a preset id";
95
- }
96
- if (!targetSource.SCREEN_PRESETS.some((p) => p.id === id)) {
97
- return `unknown preset "${id}" — valid ids: ${idList(targetSource.SCREEN_PRESETS.map((p) => p.id))}`;
98
- }
99
- return null;
100
- }
101
- function profileApplyError(id) {
102
- if (typeof id !== "string") return "setProfile payload must be { id: string }";
103
- if (!targetSource.PANEL_PROFILES.some((p) => p.id === id)) {
104
- return `unknown profile "${id}" — valid ids: ${idList(targetSource.PANEL_PROFILES.map((p) => p.id))}`;
105
- }
106
- return null;
107
- }
108
- function viewModeApplyError(v) {
109
- return v === "1:1" || v === "fit" ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
110
- }
111
71
  const MAX_RECT = 16384;
112
72
  const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
113
- const isRecord = (v) => typeof v === "object" && v !== null;
73
+ const isRecord$1 = (v) => typeof v === "object" && v !== null;
114
74
  function parseRect(raw) {
115
- if (!isRecord(raw)) return null;
75
+ if (!isRecord$1(raw)) return null;
116
76
  const { x, y, width, height } = raw;
117
77
  if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height)) return null;
118
78
  const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
@@ -134,7 +94,7 @@ function parseModifiers(raw) {
134
94
  return raw.filter((m) => typeof m === "string" && MODIFIERS.has(m));
135
95
  }
136
96
  function parseInputEvent(raw) {
137
- if (!isRecord(raw)) return null;
97
+ if (!isRecord$1(raw)) return null;
138
98
  const modifiers = parseModifiers(raw.modifiers);
139
99
  switch (raw.type) {
140
100
  case "mouseDown":
@@ -167,7 +127,7 @@ function parseDeviceScaleFactor(raw) {
167
127
  return raw;
168
128
  }
169
129
  function parseSettings(raw) {
170
- if (!isRecord(raw)) return null;
130
+ if (!isRecord$1(raw)) return null;
171
131
  const { hostDiagonalInches, hostNits } = raw;
172
132
  if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0) return null;
173
133
  if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
@@ -180,20 +140,104 @@ function parseMode(raw) {
180
140
  }
181
141
  const MAX_UI_ID = 64;
182
142
  function parseUiState(raw) {
183
- if (!isRecord(raw)) return null;
143
+ if (!isRecord$1(raw)) return null;
184
144
  const { presetId, profileId, viewMode, mode } = raw;
185
145
  if (typeof presetId !== "string" || presetId.length === 0 || presetId.length > MAX_UI_ID) return null;
186
146
  if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
187
147
  if (viewMode !== "1:1" && viewMode !== "fit") return null;
188
148
  if (mode !== "url" && mode !== "image") return null;
189
- return { presetId, profileId, viewMode, mode };
149
+ return { presetId, profileId, viewMode, mode, targetBounds: parseRect(raw.targetBounds) };
190
150
  }
191
151
  function parseScrollPos(raw) {
192
- if (!isRecord(raw)) return null;
152
+ if (!isRecord$1(raw)) return null;
193
153
  const { x, y } = raw;
194
154
  if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0) return null;
195
155
  return { x, y };
196
156
  }
157
+ const CONTROL_FILE_NAME = "control.json";
158
+ const CONTROL_TOKEN_BYTES = 32;
159
+ const CONTROL_COMMANDS = [
160
+ "status",
161
+ "navigate",
162
+ "setPreset",
163
+ "setProfile",
164
+ "setViewMode",
165
+ "captureVisible",
166
+ // v0.5 drive controls (spec §14 "Drive controls").
167
+ "scroll",
168
+ "panTo",
169
+ "click",
170
+ "highlight",
171
+ "back",
172
+ "forward",
173
+ "reload",
174
+ "setPixelExact",
175
+ "captureTarget",
176
+ "focusWindow"
177
+ ];
178
+ function isControlCommand(v) {
179
+ return typeof v === "string" && CONTROL_COMMANDS.includes(v);
180
+ }
181
+ const isRecord = (v) => typeof v === "object" && v !== null;
182
+ function tokenEqual(expected, provided) {
183
+ if (typeof provided !== "string") return false;
184
+ const a = node_crypto.createHash("sha256").update(expected).digest();
185
+ const b = node_crypto.createHash("sha256").update(provided).digest();
186
+ return node_crypto.timingSafeEqual(a, b);
187
+ }
188
+ const idList = (ids) => ids.join(", ");
189
+ function presetApplyError(id) {
190
+ if (typeof id !== "string") return "setPreset payload must be { id: string }";
191
+ if (id === "custom") {
192
+ return "the custom preset cannot be applied remotely — it is defined by the fields in the app; pick a preset id";
193
+ }
194
+ if (!targetSource.SCREEN_PRESETS.some((p) => p.id === id)) {
195
+ return `unknown preset "${id}" — valid ids: ${idList(targetSource.SCREEN_PRESETS.map((p) => p.id))}`;
196
+ }
197
+ return null;
198
+ }
199
+ function profileApplyError(id) {
200
+ if (typeof id !== "string") return "setProfile payload must be { id: string }";
201
+ if (!targetSource.PANEL_PROFILES.some((p) => p.id === id)) {
202
+ return `unknown profile "${id}" — valid ids: ${idList(targetSource.PANEL_PROFILES.map((p) => p.id))}`;
203
+ }
204
+ return null;
205
+ }
206
+ function viewModeApplyError(v) {
207
+ return v === "1:1" || v === "fit" ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
208
+ }
209
+ function pixelExactApplyError(v) {
210
+ return typeof v === "boolean" ? null : "setPixelExact payload must be { on: boolean }";
211
+ }
212
+ function parseClick(raw, viewport) {
213
+ const shape = "click payload must be { x, y, button? } with finite CSS-pixel coordinates";
214
+ if (!isRecord(raw)) return shape;
215
+ const { x, y } = raw;
216
+ if (typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y)) return shape;
217
+ if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) {
218
+ return `click (${x}, ${y}) is outside the current CSS viewport ${viewport.width}x${viewport.height}`;
219
+ }
220
+ const button = raw.button ?? "left";
221
+ if (button !== "left" && button !== "middle" && button !== "right") {
222
+ return "click button must be left, middle or right";
223
+ }
224
+ return { x, y, button };
225
+ }
226
+ const HIGHLIGHT_DURATION_DEFAULT_MS = 2e3;
227
+ const HIGHLIGHT_DURATION_MIN_MS = 250;
228
+ const HIGHLIGHT_DURATION_MAX_MS = 1e4;
229
+ function parseHighlight(raw) {
230
+ const rect = parseRect(raw);
231
+ if (!rect) return "highlight payload must be { x, y, width, height, durationMs? } with finite, non-negative target-pixel bounds";
232
+ if (rect.width < 1 || rect.height < 1) return "highlight rect must be at least 1x1 target pixels";
233
+ const d = raw.durationMs;
234
+ if (d === void 0) return { ...rect, durationMs: HIGHLIGHT_DURATION_DEFAULT_MS };
235
+ if (typeof d !== "number" || !Number.isFinite(d)) return "highlight durationMs must be a finite number of milliseconds";
236
+ return {
237
+ ...rect,
238
+ durationMs: Math.min(Math.max(Math.round(d), HIGHLIGHT_DURATION_MIN_MS), HIGHLIGHT_DURATION_MAX_MS)
239
+ };
240
+ }
197
241
  const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
198
242
  function loadSettings(file) {
199
243
  try {
@@ -325,6 +369,52 @@ class ControlServer {
325
369
  const capture = await this.deps.captureVisible();
326
370
  return reply(200, { ok: true, ...capture });
327
371
  }
372
+ case "captureTarget": {
373
+ const capture = await this.deps.captureTarget();
374
+ return reply(200, { ok: true, ...capture });
375
+ }
376
+ case "scroll": {
377
+ const pos = parseScrollPos(payload);
378
+ if (!pos) return reply(400, { error: "scroll payload must be { x, y } with finite, non-negative CSS-pixel offsets" });
379
+ this.deps.scroll(pos);
380
+ return reply(200, { ok: true });
381
+ }
382
+ case "panTo": {
383
+ const pos = parseScrollPos(payload);
384
+ if (!pos) return reply(400, { error: "panTo payload must be { x, y } with finite, non-negative target-pixel coordinates" });
385
+ this.deps.apply({ panTo: pos });
386
+ return reply(200, { ok: true });
387
+ }
388
+ case "click": {
389
+ const click = parseClick(payload, this.deps.viewport());
390
+ if (typeof click === "string") return reply(400, { error: click });
391
+ this.deps.click(click);
392
+ return reply(200, { ok: true });
393
+ }
394
+ case "highlight": {
395
+ const highlight = parseHighlight(payload);
396
+ if (typeof highlight === "string") return reply(400, { error: highlight });
397
+ this.deps.apply({ highlight });
398
+ return reply(200, { ok: true });
399
+ }
400
+ case "back":
401
+ this.deps.back();
402
+ return reply(200, { ok: true });
403
+ case "forward":
404
+ this.deps.forward();
405
+ return reply(200, { ok: true });
406
+ case "reload":
407
+ this.deps.reload();
408
+ return reply(200, { ok: true });
409
+ case "setPixelExact": {
410
+ const err = pixelExactApplyError(payload.on);
411
+ if (err) return reply(400, { error: err });
412
+ this.deps.apply({ pixelExact: payload.on });
413
+ return reply(200, { ok: true });
414
+ }
415
+ case "focusWindow":
416
+ this.deps.focusWindow();
417
+ return reply(200, { ok: true });
328
418
  }
329
419
  }
330
420
  /**
@@ -399,18 +489,23 @@ function registerIpc(ctx) {
399
489
  assertRenderer(e);
400
490
  return navigateBoth(url);
401
491
  });
402
- electron.ipcMain.on(IPC.reload, (e) => {
403
- if (!fromRenderer(e)) return;
492
+ const reloadBoth = () => {
404
493
  native.reload();
405
494
  target.reload();
495
+ };
496
+ const goBack = () => native.back();
497
+ const goForward = () => native.forward();
498
+ electron.ipcMain.on(IPC.reload, (e) => {
499
+ if (!fromRenderer(e)) return;
500
+ reloadBoth();
406
501
  });
407
502
  electron.ipcMain.on(IPC.back, (e) => {
408
503
  if (!fromRenderer(e)) return;
409
- native.back();
504
+ goBack();
410
505
  });
411
506
  electron.ipcMain.on(IPC.forward, (e) => {
412
507
  if (!fromRenderer(e)) return;
413
- native.forward();
508
+ goForward();
414
509
  });
415
510
  electron.ipcMain.handle(IPC.setViewport, (e, width, height, rawDsf) => {
416
511
  assertRenderer(e);
@@ -488,6 +583,7 @@ function registerIpc(ctx) {
488
583
  if (s.agentControl !== wasEnabled) applyAgentControl(s.agentControl);
489
584
  });
490
585
  const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "1:1", mode: "url" };
586
+ let targetBounds = null;
491
587
  const MAX_PENDING_APPLIES = 32;
492
588
  let rendererReported = false;
493
589
  let warnedPendingOverflow = false;
@@ -496,7 +592,9 @@ function registerIpc(ctx) {
496
592
  if (!fromRenderer(e)) return;
497
593
  const s = parseUiState(raw);
498
594
  if (!s) return;
499
- Object.assign(uiState, s);
595
+ const { targetBounds: bounds, ...state } = s;
596
+ Object.assign(uiState, state);
597
+ targetBounds = bounds ?? null;
500
598
  if (!rendererReported) {
501
599
  rendererReported = true;
502
600
  for (const patch of pendingApplies.splice(0)) {
@@ -542,6 +640,42 @@ function registerIpc(ctx) {
542
640
  const size = image.getSize();
543
641
  return { data: image.toPNG().toString("base64"), width: size.width, height: size.height };
544
642
  },
643
+ captureTarget: async () => {
644
+ const bounds = targetBounds;
645
+ const known = bounds !== null && bounds.width >= 1 && bounds.height >= 1;
646
+ const image = await win.webContents.capturePage(known ? bounds : void 0);
647
+ const size = image.getSize();
648
+ return {
649
+ data: image.toPNG().toString("base64"),
650
+ width: size.width,
651
+ height: size.height,
652
+ warnings: known ? [] : ["the renderer has not reported the target pane bounds yet; captured the full window instead"]
653
+ };
654
+ },
655
+ viewport: () => target.getViewport(),
656
+ scroll: (pos) => {
657
+ for (const wc of [native.webContents, target.webContents]) {
658
+ if (!wc.isDestroyed()) wc.send(IPC.applyScroll, pos);
659
+ }
660
+ },
661
+ click: (c) => {
662
+ const down = parseInputEvent({ type: "mouseDown", x: c.x, y: c.y, button: c.button, clickCount: 1 });
663
+ const up = parseInputEvent({ type: "mouseUp", x: c.x, y: c.y, button: c.button, clickCount: 1 });
664
+ if (!down || !up) return;
665
+ try {
666
+ target.sendInput(down);
667
+ target.sendInput(up);
668
+ } catch {
669
+ }
670
+ },
671
+ back: goBack,
672
+ forward: goForward,
673
+ reload: reloadBoth,
674
+ focusWindow: () => {
675
+ if (win.isDestroyed()) return;
676
+ win.show();
677
+ win.focus();
678
+ },
545
679
  activity: () => {
546
680
  if (!win.isDestroyed()) win.webContents.send(IPC.agentActivity);
547
681
  }
package/out/mcp/lib.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.APP_NOT_REACHABLE = exports.urlSchemeError = exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
3
+ exports.PANE_CAPTURE_HEADLESS_NOTE = 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;
@@ -105,6 +105,7 @@ Object.defineProperty(exports, "ALLOWED_URL_SCHEMES", { enumerable: true, get: f
105
105
  Object.defineProperty(exports, "urlSchemeError", { enumerable: true, get: function () { return url_1.urlSchemeError; } });
106
106
  exports.APP_NOT_REACHABLE = 'The Obsrv app is not reachable. Open the Obsrv desktop app and enable "Agent control" in the toolbar ' +
107
107
  '(or pass mode: "headless" to render without it).';
108
+ exports.PANE_CAPTURE_HEADLESS_NOTE = "capture: 'pane' applies to live mode only; the headless render is the page raster itself, so the option was ignored.";
108
109
  /**
109
110
  * Decides whether an `obsrv_snap` call drives the visible app or renders
110
111
  * headlessly (spec §14 "Live drive"), given whether a control-enabled app
@@ -117,16 +118,19 @@ exports.APP_NOT_REACHABLE = 'The Obsrv app is not reachable. Open the Obsrv desk
117
118
  * full page), with a note.
118
119
  * - `waitMs` is honoured headlessly; on the live path it is ignored with a
119
120
  * note (the live capture settles on the app's own committed navigation).
121
+ * - `capture: 'pane'` shapes the live capture only; any headless outcome
122
+ * notes that it was ignored.
120
123
  * - `mode: 'live'` with no reachable app is an error, never a silent
121
124
  * headless fallback — the caller asked to watch.
122
125
  */
123
126
  function planSnapPath(input, mode, liveReachable) {
127
+ const paneNote = input.capture === 'pane' ? [exports.PANE_CAPTURE_HEADLESS_NOTE] : [];
124
128
  if (mode === 'headless')
125
- return { path: 'headless', notes: [] };
129
+ return { path: 'headless', notes: paneNote };
126
130
  if (!liveReachable) {
127
131
  if (mode === 'live')
128
132
  return { error: exports.APP_NOT_REACHABLE };
129
- return { path: 'headless', notes: [] };
133
+ return { path: 'headless', notes: paneNote };
130
134
  }
131
135
  const notes = [];
132
136
  const custom = input.width !== undefined ||
@@ -138,7 +142,7 @@ function planSnapPath(input, mode, liveReachable) {
138
142
  if (input.fullPage)
139
143
  notes.push('fullPage is headless-only; rendered headlessly instead of driving the app.');
140
144
  if (notes.length > 0)
141
- return { path: 'headless', notes };
145
+ return { path: 'headless', notes: [...notes, ...paneNote] };
142
146
  if (input.waitMs !== undefined)
143
147
  notes.push('waitMs is headless-only and was ignored in live mode.');
144
148
  return { path: 'live', notes };
package/out/mcp/server.js CHANGED
@@ -123,6 +123,11 @@ const snapInputShape = {
123
123
  .optional()
124
124
  .describe('auto (default): drive the visible Obsrv app when it is open with Agent control on, else render headlessly. ' +
125
125
  'live: require the app (error if unreachable). headless: never touch the app.'),
126
+ capture: zod_1.z
127
+ .enum(['window', 'pane'])
128
+ .optional()
129
+ .describe("Live mode only: what the returned PNG shows — 'window' (default) is the whole app window, 'pane' is just " +
130
+ 'the target pane (its footer readout included). Ignored (with a note) when the render is headless.'),
126
131
  };
127
132
  const snapOutputShape = {
128
133
  mode: zod_1.z
@@ -143,8 +148,16 @@ const snapOutputShape = {
143
148
  presetId: zod_1.z.string().optional().describe('Live only: the screen preset selected in the app.'),
144
149
  profileId: zod_1.z.string().optional().describe('Live only: the panel profile selected in the app.'),
145
150
  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.'),
151
+ width: zod_1.z
152
+ .number()
153
+ .optional()
154
+ .describe('Live only: captured width in device-independent px (the app window, or the target pane under capture: ' +
155
+ '"pane"); the PNG raster is this times the display scale.'),
156
+ height: zod_1.z
157
+ .number()
158
+ .optional()
159
+ .describe('Live only: captured height in device-independent px (the app window, or the target pane under capture: ' +
160
+ '"pane"); the PNG raster is this times the display scale.'),
148
161
  };
149
162
  const diffInputShape = {
150
163
  url: urlField,
@@ -214,6 +227,34 @@ const driveInputShape = {
214
227
  preset: zod_1.z.enum(PRESET_IDS).optional().describe('Apply this screen preset, exactly as clicking the toolbar would.'),
215
228
  profile: zod_1.z.enum(PROFILE_IDS).optional().describe('Apply this panel profile in the app.'),
216
229
  viewMode: zod_1.z.enum(['1:1', 'fit']).optional().describe("Switch the app's target pane between 1:1 (actual size) and fit."),
230
+ pixelExact: zod_1.z.boolean().optional().describe("Toggle the toolbar's pixel-exact checkbox (pins the magnification to the host scale)."),
231
+ focus: zod_1.z.boolean().optional().describe('true: bring the Obsrv window to the front first, so the user sees what follows.'),
232
+ reload: zod_1.z.boolean().optional().describe('true: reload both panes (the same action as the toolbar reload).'),
233
+ back: zod_1.z.boolean().optional().describe('true: history back (native pane history; the target mirrors the committed page).'),
234
+ forward: zod_1.z.boolean().optional().describe('true: history forward (native pane history; the target mirrors it).'),
235
+ scroll: zod_1.z
236
+ .object({ x: zod_1.z.number().min(0), y: zod_1.z.number().min(0) })
237
+ .optional()
238
+ .describe('Scroll both panes to this absolute page offset in CSS px.'),
239
+ panTo: zod_1.z
240
+ .object({ x: zod_1.z.number().min(0), y: zod_1.z.number().min(0) })
241
+ .optional()
242
+ .describe("Centre this target-pane pixel (device px of the render) in the pane's 1:1 view; from fit this jumps to 1:1 there."),
243
+ click: zod_1.z
244
+ .object({ x: zod_1.z.number().min(0), y: zod_1.z.number().min(0) })
245
+ .optional()
246
+ .describe('Left-click the live page at these CSS-viewport coordinates (may navigate; refused outside the viewport).'),
247
+ highlight: zod_1.z
248
+ .object({
249
+ x: zod_1.z.number().min(0),
250
+ y: zod_1.z.number().min(0),
251
+ width: zod_1.z.number().min(1),
252
+ height: zod_1.z.number().min(1),
253
+ durationMs: zod_1.z.number().optional(),
254
+ })
255
+ .optional()
256
+ .describe('Draw a temporary neutral marker over this target-pixel rect in the pane (durationMs default 2000, clamped ' +
257
+ '250-10000). A new highlight replaces the previous one.'),
217
258
  };
218
259
  const driveOutputShape = {
219
260
  version: zod_1.z.string().describe('The running app version.'),
@@ -232,6 +273,13 @@ const LIVE_APPLY_TIMEOUT_MS = 5_000;
232
273
  const LIVE_CAPTURE_TIMEOUT_MS = 30_000;
233
274
  /** How long a live snap waits for `status.url` to reflect the navigation. */
234
275
  const LIVE_SETTLE_MS = 5_000;
276
+ /**
277
+ * How long an `obsrv_drive` click waits for a navigation it may have caused,
278
+ * so the returned status reflects it. Deliberately short: most clicks do not
279
+ * navigate, and every non-navigating one pays this in full.
280
+ */
281
+ const CLICK_SETTLE_MS = 2_000;
282
+ const CLICK_SETTLE_POLL_MS = 250;
235
283
  function liveFailure(e) {
236
284
  const msg = e instanceof Error ? e.message : String(e);
237
285
  return (`${msg}. If the Obsrv app was closed or Agent control was toggled off mid-call, ` +
@@ -287,9 +335,13 @@ async function liveSnap(app, input, notes) {
287
335
  // (and any preset resize) a frame or two after the store confirms, and a
288
336
  // capture racing that would show a half-applied flip.
289
337
  await sleep(300);
338
+ // `capture: 'pane'` crops to the target pane; the command answers with the
339
+ // same { data, width, height } shape plus its own warnings (e.g. the
340
+ // pre-mount full-window fallback), which join the tool's.
290
341
  let capture;
291
342
  try {
292
- capture = await (0, control_2.controlCall)(info, 'captureVisible', {}, LIVE_CAPTURE_TIMEOUT_MS);
343
+ const command = input.capture === 'pane' ? 'captureTarget' : 'captureVisible';
344
+ capture = await (0, control_2.controlCall)(info, command, {}, LIVE_CAPTURE_TIMEOUT_MS);
293
345
  }
294
346
  catch (e) {
295
347
  return toolError(liveFailure(e));
@@ -298,6 +350,11 @@ async function liveSnap(app, input, notes) {
298
350
  if (typeof data !== 'string' || typeof width !== 'number' || typeof height !== 'number') {
299
351
  return toolError('the control server returned a malformed capture');
300
352
  }
353
+ if (Array.isArray(capture['warnings'])) {
354
+ for (const w of capture['warnings'])
355
+ if (typeof w === 'string')
356
+ warnings.push(w);
357
+ }
301
358
  const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
302
359
  const pngPath = (0, node_path_1.join)(dir, 'live.png');
303
360
  await (0, promises_1.writeFile)(pngPath, Buffer.from(data, 'base64'));
@@ -336,7 +393,8 @@ server.registerTool('obsrv_snap', {
336
393
  `Live drive: when the Obsrv desktop app is open with its "Agent control" toolbar toggle on, \`mode: "auto"\` ` +
337
394
  `(the default) drives the *visible* app instead — the user watches the URL load and the preset flip, and the ` +
338
395
  `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 ` +
396
+ `otherwise). \`capture: "pane"\` crops a live capture to just the target pane (headless renders ignore it ` +
397
+ `with a note). Custom width/height and \`fullPage\` always render headlessly (with a note); \`waitMs\` is ` +
340
398
  `ignored in live mode. \`mode: "live"\` errors when the app is not reachable; \`mode: "headless"\` never ` +
341
399
  `touches it. Note: although this tool is annotated read-only (it renders and captures), a live snap steers ` +
342
400
  `the open app window — navigating it and flipping its preset in front of the user — as its means of ` +
@@ -362,6 +420,9 @@ server.registerTool('obsrv_snap', {
362
420
  return liveSnap(live, input, plan.notes);
363
421
  liveNotes = plan.notes;
364
422
  }
423
+ else if (input.capture === 'pane') {
424
+ liveNotes = [lib_1.PANE_CAPTURE_HEADLESS_NOTE];
425
+ }
365
426
  const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
366
427
  const pngPath = (0, node_path_1.join)(dir, 'snap.png');
367
428
  let args;
@@ -436,12 +497,20 @@ server.registerTool('obsrv_diff', {
436
497
  server.registerTool('obsrv_drive', {
437
498
  title: 'Drive the visible Obsrv app',
438
499
  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` +
500
+ `profile, the target pane's 1:1/fit view or pixel-exact toggle — each exactly as clicking the toolbar would ` +
501
+ `— and steer the session like a guided demo: focus the window, step history (back/forward/reload), scroll ` +
502
+ `both panes, pan the target pane to a pixel, click the live page, and highlight a rect with a temporary ` +
503
+ `neutral marker, all while the user watches.\n\n` +
504
+ `Only the supplied inputs run (none = just read the current state), in this fixed order: focus → url → ` +
505
+ `preset → profile → viewMode → pixelExact → reload → back → forward → scroll → panTo → click → highlight. ` +
506
+ `The result is the final status: app version, the URL showing, and the selected preset/profile/view. A ` +
507
+ `click that navigates is reflected in that status — the call waits briefly (up to 2 s) for the commit. ` +
508
+ `Coordinates: click takes CSS-viewport px of the page (the valid range is 0 up to but not including the ` +
509
+ `viewport size); panTo and highlight take target-pane pixels (device px of the render — identical to CSS px ` +
510
+ `on 1x presets); scroll takes page CSS px.\n\n` +
442
511
  `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.`,
512
+ `mutates visible app state (it changes what the user's window shows, and a click can act on the live page) ` +
513
+ `but renders nothing itself — use obsrv_snap for a capture.`,
445
514
  inputSchema: driveInputShape,
446
515
  outputSchema: driveOutputShape,
447
516
  // Honest annotation: this changes what the user's window is showing.
@@ -456,6 +525,10 @@ server.registerTool('obsrv_drive', {
456
525
  if (!live)
457
526
  return toolError(lib_1.APP_NOT_REACHABLE);
458
527
  try {
528
+ // The documented execution order: window attention first, then what is
529
+ // showing, then how it is shown, then the in-page steering.
530
+ if (input.focus)
531
+ await (0, control_2.controlCall)(live.info, 'focusWindow', {}, LIVE_APPLY_TIMEOUT_MS);
459
532
  if (input.url !== undefined) {
460
533
  await (0, control_2.controlCall)(live.info, 'navigate', { url: input.url.trim() }, args_1.DEFAULT_TIMEOUT_MS + 10_000);
461
534
  }
@@ -466,6 +539,38 @@ server.registerTool('obsrv_drive', {
466
539
  if (input.viewMode !== undefined) {
467
540
  await (0, control_2.controlCall)(live.info, 'setViewMode', { mode: input.viewMode }, LIVE_APPLY_TIMEOUT_MS);
468
541
  }
542
+ if (input.pixelExact !== undefined) {
543
+ await (0, control_2.controlCall)(live.info, 'setPixelExact', { on: input.pixelExact }, LIVE_APPLY_TIMEOUT_MS);
544
+ }
545
+ if (input.reload)
546
+ await (0, control_2.controlCall)(live.info, 'reload', {}, LIVE_APPLY_TIMEOUT_MS);
547
+ if (input.back)
548
+ await (0, control_2.controlCall)(live.info, 'back', {}, LIVE_APPLY_TIMEOUT_MS);
549
+ if (input.forward)
550
+ await (0, control_2.controlCall)(live.info, 'forward', {}, LIVE_APPLY_TIMEOUT_MS);
551
+ if (input.scroll !== undefined)
552
+ await (0, control_2.controlCall)(live.info, 'scroll', input.scroll, LIVE_APPLY_TIMEOUT_MS);
553
+ if (input.panTo !== undefined)
554
+ await (0, control_2.controlCall)(live.info, 'panTo', input.panTo, LIVE_APPLY_TIMEOUT_MS);
555
+ if (input.click !== undefined) {
556
+ // A click may navigate. Note the URL first, then wait — bounded and
557
+ // short, the same settle idea as a live snap — for the status to move
558
+ // off it, so the returned status reflects what the click did. A click
559
+ // that navigates nowhere simply rides out the short deadline.
560
+ const before = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS))?.url ?? '';
561
+ await (0, control_2.controlCall)(live.info, 'click', input.click, LIVE_APPLY_TIMEOUT_MS);
562
+ const deadline = Date.now() + CLICK_SETTLE_MS;
563
+ for (;;) {
564
+ const s = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
565
+ if (s && s.url !== before && s.url !== 'about:blank')
566
+ break;
567
+ if (Date.now() >= deadline)
568
+ break;
569
+ await sleep(CLICK_SETTLE_POLL_MS);
570
+ }
571
+ }
572
+ if (input.highlight !== undefined)
573
+ await (0, control_2.controlCall)(live.info, 'highlight', input.highlight, LIVE_APPLY_TIMEOUT_MS);
469
574
  const status = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
470
575
  if (!status)
471
576
  return toolError('the control server returned a malformed status');
@@ -377,6 +377,9 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
377
377
  position: fixed;
378
378
  right: 16px;
379
379
  bottom: 16px;
380
+ /* Above the stall notice (2) and the agent highlight (1): these are the
381
+ app talking to the user and must not sit under an overlay in the pane. */
382
+ z-index: 3;
380
383
  padding: 8px 12px;
381
384
  background: var(--chrome-2);
382
385
  color: var(--text-0);
@@ -393,12 +396,28 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
393
396
  `left: 0` holds while a wide canvas is scrolled horizontally. The fill is a
394
397
  neutral grey: only the rule under it is red, so the one chromatic thing on
395
398
  screen is the error itself. */
396
- .target-wrap { min-width: min-content; }
399
+ /* position: relative anchors the agent highlight to the canvas origin. */
400
+ .target-wrap { min-width: min-content; position: relative; }
401
+
402
+ /* Agent-control highlight: a temporary neutral marker over the target-pixel
403
+ rect an agent is pointing at. Style-spec law: no hue — a light outline with
404
+ a dark dashed inner edge reads on any page without tinting it — and no
405
+ pointer interception over the pixels under inspection. */
406
+ .agent-highlight {
407
+ position: absolute;
408
+ z-index: 1;
409
+ pointer-events: none;
410
+ outline: 2px solid var(--text-0);
411
+ border: 1px dashed var(--chrome-0);
412
+ box-sizing: border-box;
413
+ }
397
414
  .stall {
398
415
  position: sticky;
399
416
  top: 0;
400
417
  left: 0;
401
- z-index: 1;
418
+ /* Above the agent highlight (z-index 1): an error notice outranks a
419
+ demo marker. */
420
+ z-index: 2;
402
421
  display: flex;
403
422
  align-items: center;
404
423
  gap: 10px;
@@ -12777,11 +12777,17 @@ const useStore = create()((set) => ({
12777
12777
  surround: "graphite",
12778
12778
  viewMode: "1:1",
12779
12779
  fitScale: null,
12780
+ agentPan: null,
12781
+ agentHighlight: null,
12780
12782
  // Does not clear `error`: a failed load navigates to Chromium's error page,
12781
12783
  // so clearing here would wipe the toolbar badge the moment it appeared.
12782
- setUrl: (url) => set({ url }),
12783
- setPreset: (presetId) => set({ presetId }),
12784
- setCustom: (c) => set((s) => ({ custom: { ...s.custom, ...c }, presetId: CUSTOM_PRESET_ID })),
12784
+ // Does clear the agent highlight: it marked pixels of the page that was
12785
+ // showing, and a committed navigation (a reload included) replaces them.
12786
+ setUrl: (url) => set({ url, agentHighlight: null }),
12787
+ // A screen change re-rasters the target, so a highlight's target-pixel rect
12788
+ // no longer marks what it marked; the same for the custom fields below.
12789
+ setPreset: (presetId) => set({ presetId, agentHighlight: null }),
12790
+ setCustom: (c) => set((s) => ({ custom: { ...s.custom, ...c }, presetId: CUSTOM_PRESET_ID, agentHighlight: null })),
12785
12791
  setPixelExact: (pixelExact) => set({ pixelExact }),
12786
12792
  // Picking a profile drops any hand-tuned slider values.
12787
12793
  setProfile: (profileId) => set({ profileId, profileOverride: null }),
@@ -12797,9 +12803,15 @@ const useStore = create()((set) => ({
12797
12803
  setSurround: (surround) => set({ surround }),
12798
12804
  setViewMode: (viewMode) => set({ viewMode }),
12799
12805
  setFitScale: (fitScale2) => set({ fitScale: fitScale2 }),
12806
+ requestAgentPan: (p) => set((s) => ({ agentPan: { ...p, seq: (s.agentPan?.seq ?? 0) + 1 } })),
12807
+ clearAgentPan: () => set({ agentPan: null }),
12808
+ showAgentHighlight: (h) => set((s) => ({ agentHighlight: { ...h, seq: (s.agentHighlight?.seq ?? 0) + 1 } })),
12809
+ clearAgentHighlight: (seq) => set((s) => seq === void 0 || s.agentHighlight?.seq === seq ? { agentHighlight: null } : {}),
12800
12810
  // Spec §7: leaving image mode restores the URL that was showing before.
12811
+ // Either direction swaps what the target pane shows, so a highlight over
12812
+ // the old content is dropped with it.
12801
12813
  setMode: (mode) => set(
12802
- (s) => mode === s.mode ? {} : mode === "image" ? { mode, lastUrl: s.url } : { mode, url: s.lastUrl, image: null }
12814
+ (s) => mode === s.mode ? {} : mode === "image" ? { mode, lastUrl: s.url, agentHighlight: null } : { mode, url: s.lastUrl, image: null, agentHighlight: null }
12803
12815
  )
12804
12816
  }));
12805
12817
  function selectScreen(s) {
@@ -13797,17 +13809,30 @@ function computeFitScale(paneW, paneH, dpr, vpW, vpH, oneToOneScale) {
13797
13809
  if (!usable(paneW, paneH, dpr, vpW, vpH, oneToOneScale)) return 1;
13798
13810
  return Math.min(paneW * dpr / vpW, paneH * dpr / vpH, oneToOneScale);
13799
13811
  }
13800
- function jumpScroll(clickX, clickY, dpr, fitScale2, oneToOneScale, paneW, paneH, vpW, vpH) {
13801
- if (!usable(dpr, fitScale2, oneToOneScale, paneW, paneH, vpW, vpH) || !Number.isFinite(clickX) || !Number.isFinite(clickY)) {
13812
+ function centreScroll(x, y, dpr, oneToOneScale, paneW, paneH, vpW, vpH) {
13813
+ if (!usable(dpr, oneToOneScale, paneW, paneH, vpW, vpH) || !Number.isFinite(x) || !Number.isFinite(y)) {
13802
13814
  return { left: 0, top: 0 };
13803
13815
  }
13804
- const axis = (click, pane, vp) => {
13805
- const target = click * dpr / fitScale2;
13816
+ const axis = (target, pane, vp) => {
13806
13817
  const want = target * oneToOneScale / dpr - pane / 2;
13807
13818
  const max = vp * oneToOneScale / dpr - pane;
13808
13819
  return Math.min(Math.max(want, 0), Math.max(max, 0));
13809
13820
  };
13810
- return { left: axis(clickX, paneW, vpW), top: axis(clickY, paneH, vpH) };
13821
+ return { left: axis(x, paneW, vpW), top: axis(y, paneH, vpH) };
13822
+ }
13823
+ function jumpScroll(clickX, clickY, dpr, fitScale2, oneToOneScale, paneW, paneH, vpW, vpH) {
13824
+ if (!usable(fitScale2) || !Number.isFinite(clickX) || !Number.isFinite(clickY)) return { left: 0, top: 0 };
13825
+ if (!usable(dpr)) return { left: 0, top: 0 };
13826
+ return centreScroll(
13827
+ clickX * dpr / fitScale2,
13828
+ clickY * dpr / fitScale2,
13829
+ dpr,
13830
+ oneToOneScale,
13831
+ paneW,
13832
+ paneH,
13833
+ vpW,
13834
+ vpH
13835
+ );
13811
13836
  }
13812
13837
  const STALL_MS = 2e3;
13813
13838
  function TargetCanvas({ onFatal, imageFrame }) {
@@ -14075,6 +14100,30 @@ function TargetCanvas({ onFatal, imageFrame }) {
14075
14100
  );
14076
14101
  setViewMode("1:1");
14077
14102
  };
14103
+ const agentPan = useStore((s) => s.agentPan);
14104
+ const clearAgentPan = useStore((s) => s.clearAgentPan);
14105
+ reactExports.useEffect(() => {
14106
+ if (!agentPan) return;
14107
+ clearAgentPan();
14108
+ const jump = centreScroll(agentPan.x, agentPan.y, dpr, oneToOne, pane.width, pane.height, source.width, source.height);
14109
+ if (viewMode === "fit") {
14110
+ pendingJump.current = jump;
14111
+ setViewMode("1:1");
14112
+ return;
14113
+ }
14114
+ const body = paneBody();
14115
+ if (body) {
14116
+ body.scrollLeft = jump.left;
14117
+ body.scrollTop = jump.top;
14118
+ }
14119
+ }, [agentPan]);
14120
+ const agentHighlight = useStore((s) => s.agentHighlight);
14121
+ const clearAgentHighlight = useStore((s) => s.clearAgentHighlight);
14122
+ reactExports.useEffect(() => {
14123
+ if (!agentHighlight) return;
14124
+ const t = window.setTimeout(() => clearAgentHighlight(agentHighlight.seq), agentHighlight.durationMs);
14125
+ return () => window.clearTimeout(t);
14126
+ }, [agentHighlight, clearAgentHighlight]);
14078
14127
  const send = (type) => (e) => {
14079
14128
  if (mode !== "url") return;
14080
14129
  if (viewMode !== "1:1" || panRef.current || e.button === 1) return;
@@ -14103,33 +14152,52 @@ function TargetCanvas({ onFatal, imageFrame }) {
14103
14152
  }
14104
14153
  )
14105
14154
  ] }),
14106
- /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "target-wrap", children: /* @__PURE__ */ jsxRuntimeExports.jsx(
14107
- "canvas",
14108
- {
14109
- ref: canvasRef,
14110
- className: `target-canvas${fit ? " fit" : panning ? " panning" : altHeld ? " pan-ready" : ""}`,
14111
- tabIndex: 0,
14112
- style: { width: `${cssW}px`, height: `${cssH}px` },
14113
- onClick: jumpTo1x,
14114
- onPointerDown: startPan,
14115
- onPointerMove: movePan,
14116
- onPointerUp: (e) => endPan(e, false),
14117
- onPointerCancel: (e) => endPan(e, true),
14118
- onMouseDown: send("mouseDown"),
14119
- onMouseUp: send("mouseUp"),
14120
- onMouseMove: send("mouseMove"),
14121
- onKeyDown: (e) => {
14122
- if (mode !== "url" || viewMode !== "1:1") return;
14123
- if (!e.metaKey && !e.ctrlKey) e.preventDefault();
14124
- for (const ev of keyDownEvents(e)) window.obsrv.sendInput(ev);
14125
- },
14126
- onKeyUp: (e) => {
14127
- if (mode !== "url" || viewMode !== "1:1") return;
14128
- const ev = keyUpEvent(e);
14129
- if (ev) window.obsrv.sendInput(ev);
14155
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "target-wrap", children: [
14156
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
14157
+ "canvas",
14158
+ {
14159
+ ref: canvasRef,
14160
+ className: `target-canvas${fit ? " fit" : panning ? " panning" : altHeld ? " pan-ready" : ""}`,
14161
+ tabIndex: 0,
14162
+ style: { width: `${cssW}px`, height: `${cssH}px` },
14163
+ onClick: jumpTo1x,
14164
+ onPointerDown: startPan,
14165
+ onPointerMove: movePan,
14166
+ onPointerUp: (e) => endPan(e, false),
14167
+ onPointerCancel: (e) => endPan(e, true),
14168
+ onMouseDown: send("mouseDown"),
14169
+ onMouseUp: send("mouseUp"),
14170
+ onMouseMove: send("mouseMove"),
14171
+ onKeyDown: (e) => {
14172
+ if (mode !== "url" || viewMode !== "1:1") return;
14173
+ if (!e.metaKey && !e.ctrlKey) e.preventDefault();
14174
+ for (const ev of keyDownEvents(e)) window.obsrv.sendInput(ev);
14175
+ },
14176
+ onKeyUp: (e) => {
14177
+ if (mode !== "url" || viewMode !== "1:1") return;
14178
+ const ev = keyUpEvent(e);
14179
+ if (ev) window.obsrv.sendInput(ev);
14180
+ }
14130
14181
  }
14131
- }
14132
- ) })
14182
+ ),
14183
+ agentHighlight && // The agent-control highlight: a target-pixel rect drawn at the
14184
+ // canvas's own scale, absolutely positioned inside the scroll
14185
+ // content so it rides the pane's scroll. Neutral by style-spec law
14186
+ // (no hue) and pointer-events: none, so it never intercepts the
14187
+ // input the canvas forwards.
14188
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
14189
+ "div",
14190
+ {
14191
+ className: "agent-highlight",
14192
+ style: {
14193
+ left: `${agentHighlight.x * scale / dpr}px`,
14194
+ top: `${agentHighlight.y * scale / dpr}px`,
14195
+ width: `${agentHighlight.width * scale / dpr}px`,
14196
+ height: `${agentHighlight.height * scale / dpr}px`
14197
+ }
14198
+ }
14199
+ )
14200
+ ] })
14133
14201
  ] });
14134
14202
  }
14135
14203
  const DISMISS_MS = 4e3;
@@ -14355,6 +14423,8 @@ function App() {
14355
14423
  const [drawer, setDrawer] = reactExports.useState("none");
14356
14424
  const [image, setImage] = reactExports.useState(null);
14357
14425
  const dropToken = reactExports.useRef(0);
14426
+ const targetPaneRef = reactExports.useRef(null);
14427
+ const [targetBounds, setTargetBounds] = reactExports.useState(null);
14358
14428
  const toggle = (which) => () => setDrawer((d) => d === which ? "none" : which);
14359
14429
  const setHost = useStore((s) => s.setHost);
14360
14430
  const setSettings = useStore((s) => s.setSettings);
@@ -14397,14 +14467,29 @@ function App() {
14397
14467
  window.obsrv.setMode(mode);
14398
14468
  }, [mode]);
14399
14469
  reactExports.useEffect(() => {
14400
- window.obsrv.reportUiState({ presetId, profileId, viewMode, mode });
14401
- }, [presetId, profileId, viewMode, mode]);
14470
+ const el = targetPaneRef.current;
14471
+ if (!el) return;
14472
+ const measure = () => {
14473
+ const r = el.getBoundingClientRect();
14474
+ setTargetBounds({ x: r.x, y: r.y, width: r.width, height: r.height });
14475
+ };
14476
+ const ro = new ResizeObserver(measure);
14477
+ ro.observe(el);
14478
+ measure();
14479
+ return () => ro.disconnect();
14480
+ }, []);
14481
+ reactExports.useEffect(() => {
14482
+ window.obsrv.reportUiState({ presetId, profileId, viewMode, mode, targetBounds });
14483
+ }, [presetId, profileId, viewMode, mode, targetBounds]);
14402
14484
  reactExports.useEffect(() => {
14403
14485
  return window.obsrv.onAgentApply((patch) => {
14404
14486
  const s = useStore.getState();
14405
14487
  if (patch.presetId !== void 0) s.setPreset(patch.presetId);
14406
14488
  if (patch.profileId !== void 0) s.setProfile(patch.profileId);
14407
14489
  if (patch.viewMode !== void 0) s.setViewMode(patch.viewMode);
14490
+ if (patch.pixelExact !== void 0) s.setPixelExact(patch.pixelExact);
14491
+ if (patch.panTo !== void 0) s.requestAgentPan(patch.panTo);
14492
+ if (patch.highlight !== void 0) s.showAgentHighlight(patch.highlight);
14408
14493
  });
14409
14494
  }, []);
14410
14495
  reactExports.useEffect(() => {
@@ -14476,7 +14561,7 @@ function App() {
14476
14561
  height: image.natural.height
14477
14562
  }
14478
14563
  ) : /* @__PURE__ */ jsxRuntimeExports.jsx(NativeSlot, {}),
14479
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", children: [
14564
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", ref: targetPaneRef, children: [
14480
14565
  /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "pane-body", children: /* @__PURE__ */ jsxRuntimeExports.jsx(TargetCanvas, { onFatal: setFatal, imageFrame }) }),
14481
14566
  /* @__PURE__ */ jsxRuntimeExports.jsx(TargetFooter, {})
14482
14567
  ] })
@@ -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-P496vEhv.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-FtoKShe_.css">
7
+ <script type="module" crossorigin src="./assets/index-DyCD4ih_.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-CHF-G97L.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CONTROL_COMMANDS = exports.CONTROL_TOKEN_BYTES = exports.CONTROL_FILE_NAME = void 0;
3
+ exports.HIGHLIGHT_DURATION_MAX_MS = exports.HIGHLIGHT_DURATION_MIN_MS = exports.HIGHLIGHT_DURATION_DEFAULT_MS = exports.CONTROL_COMMANDS = exports.CONTROL_TOKEN_BYTES = exports.CONTROL_FILE_NAME = void 0;
4
4
  exports.isControlCommand = isControlCommand;
5
5
  exports.parseControlFile = parseControlFile;
6
6
  exports.controlFileModeOk = controlFileModeOk;
@@ -9,9 +9,13 @@ exports.defaultControlFilePath = defaultControlFilePath;
9
9
  exports.presetApplyError = presetApplyError;
10
10
  exports.profileApplyError = profileApplyError;
11
11
  exports.viewModeApplyError = viewModeApplyError;
12
+ exports.pixelExactApplyError = pixelExactApplyError;
13
+ exports.parseClick = parseClick;
14
+ exports.parseHighlight = parseHighlight;
12
15
  exports.parseControlStatus = parseControlStatus;
13
16
  const node_crypto_1 = require("node:crypto");
14
17
  const node_path_1 = require("node:path");
18
+ const ipcPayloads_1 = require("./ipcPayloads");
15
19
  const presets_1 = require("./presets");
16
20
  /**
17
21
  * The agent-control protocol shared by the main-process control server
@@ -45,6 +49,17 @@ exports.CONTROL_COMMANDS = [
45
49
  'setProfile',
46
50
  'setViewMode',
47
51
  'captureVisible',
52
+ // v0.5 drive controls (spec §14 "Drive controls").
53
+ 'scroll',
54
+ 'panTo',
55
+ 'click',
56
+ 'highlight',
57
+ 'back',
58
+ 'forward',
59
+ 'reload',
60
+ 'setPixelExact',
61
+ 'captureTarget',
62
+ 'focusWindow',
48
63
  ];
49
64
  function isControlCommand(v) {
50
65
  return typeof v === 'string' && exports.CONTROL_COMMANDS.includes(v);
@@ -136,6 +151,64 @@ function profileApplyError(id) {
136
151
  function viewModeApplyError(v) {
137
152
  return v === '1:1' || v === 'fit' ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
138
153
  }
154
+ function pixelExactApplyError(v) {
155
+ return typeof v === 'boolean' ? null : 'setPixelExact payload must be { on: boolean }';
156
+ }
157
+ /**
158
+ * Validates a `click` payload against the target's *current* CSS viewport:
159
+ * `sendInputEvent` takes CSS coordinates, and a click past the viewport edge
160
+ * would land on nothing (or, worse, on whatever the page scrolled there),
161
+ * so it is refused rather than clamped. The coordinate space is
162
+ * `[0, width) × [0, height)` — pixel row `height` is the first one *outside*
163
+ * a `height`-pixel viewport. The button defaults to left. Returns the
164
+ * validated click, or the error message.
165
+ */
166
+ function parseClick(raw, viewport) {
167
+ const shape = 'click payload must be { x, y, button? } with finite CSS-pixel coordinates';
168
+ if (!isRecord(raw))
169
+ return shape;
170
+ const { x, y } = raw;
171
+ if (typeof x !== 'number' || !Number.isFinite(x) || typeof y !== 'number' || !Number.isFinite(y))
172
+ return shape;
173
+ if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) {
174
+ return `click (${x}, ${y}) is outside the current CSS viewport ${viewport.width}x${viewport.height}`;
175
+ }
176
+ const button = raw.button ?? 'left';
177
+ if (button !== 'left' && button !== 'middle' && button !== 'right') {
178
+ return 'click button must be left, middle or right';
179
+ }
180
+ return { x, y, button };
181
+ }
182
+ /** How long a highlight overlay stays up when the payload does not say. */
183
+ exports.HIGHLIGHT_DURATION_DEFAULT_MS = 2_000;
184
+ /** Shorter would flash imperceptibly; the payload is clamped, not refused. */
185
+ exports.HIGHLIGHT_DURATION_MIN_MS = 250;
186
+ /** Longer would squat on the pixels under inspection; clamped likewise. */
187
+ exports.HIGHLIGHT_DURATION_MAX_MS = 10_000;
188
+ /**
189
+ * Validates a `highlight` payload: the rect is checked exactly like a pane
190
+ * rect (`parseRect` — finite, non-negative, bounded, rounded) and must be at
191
+ * least 1×1 after rounding (an invisible highlight answering ok would lie).
192
+ * `durationMs` defaults and clamps rather than erroring — the exact lifetime
193
+ * is presentation, not correctness — but a non-numeric one is refused, never
194
+ * guessed. Returns the validated highlight, or the error message.
195
+ */
196
+ function parseHighlight(raw) {
197
+ const rect = (0, ipcPayloads_1.parseRect)(raw);
198
+ if (!rect)
199
+ return 'highlight payload must be { x, y, width, height, durationMs? } with finite, non-negative target-pixel bounds';
200
+ if (rect.width < 1 || rect.height < 1)
201
+ return 'highlight rect must be at least 1x1 target pixels';
202
+ const d = raw.durationMs;
203
+ if (d === undefined)
204
+ return { ...rect, durationMs: exports.HIGHLIGHT_DURATION_DEFAULT_MS };
205
+ if (typeof d !== 'number' || !Number.isFinite(d))
206
+ return 'highlight durationMs must be a finite number of milliseconds';
207
+ return {
208
+ ...rect,
209
+ durationMs: Math.min(Math.max(Math.round(d), exports.HIGHLIGHT_DURATION_MIN_MS), exports.HIGHLIGHT_DURATION_MAX_MS),
210
+ };
211
+ }
139
212
  /** Validates a control server `status` response on the client side. */
140
213
  function parseControlStatus(raw) {
141
214
  if (!isRecord(raw))
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_RECT = void 0;
4
+ exports.parseRect = parseRect;
5
+ exports.parseInputEvent = parseInputEvent;
6
+ exports.parseDeviceScaleFactor = parseDeviceScaleFactor;
7
+ exports.parseSettings = parseSettings;
8
+ exports.parseMode = parseMode;
9
+ exports.parseUiState = parseUiState;
10
+ exports.parseScrollPos = parseScrollPos;
11
+ /**
12
+ * Parsers for everything the renderer sends main over IPC. Each returns a
13
+ * fresh, fully-typed value or `null`; nothing from the wire is passed through
14
+ * by reference, so unknown keys never reach Electron, disk or `getSettings`.
15
+ * Main must never crash on a renderer message — every handler drops a `null`
16
+ * silently (or, for request/response channels, rejects the call).
17
+ */
18
+ /** Largest coordinate or size a pane rect may carry; far beyond any real window. */
19
+ exports.MAX_RECT = 16384;
20
+ const isFiniteNumber = (v) => typeof v === 'number' && Number.isFinite(v);
21
+ const isRecord = (v) => typeof v === 'object' && v !== null;
22
+ function parseRect(raw) {
23
+ if (!isRecord(raw))
24
+ return null;
25
+ const { x, y, width, height } = raw;
26
+ if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height))
27
+ return null;
28
+ const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
29
+ for (const v of [r.x, r.y, r.width, r.height])
30
+ if (v < 0 || v > exports.MAX_RECT)
31
+ return null;
32
+ return r;
33
+ }
34
+ const MODIFIERS = new Set([
35
+ 'shift',
36
+ 'control',
37
+ 'alt',
38
+ 'meta',
39
+ 'leftButtonDown',
40
+ 'middleButtonDown',
41
+ 'rightButtonDown',
42
+ ]);
43
+ const BUTTONS = new Set(['left', 'middle', 'right']);
44
+ /** Unknown entries are dropped; a missing or non-array list means no modifiers. */
45
+ function parseModifiers(raw) {
46
+ if (!Array.isArray(raw))
47
+ return [];
48
+ return raw.filter((m) => typeof m === 'string' && MODIFIERS.has(m));
49
+ }
50
+ function parseInputEvent(raw) {
51
+ if (!isRecord(raw))
52
+ return null;
53
+ const modifiers = parseModifiers(raw.modifiers);
54
+ switch (raw.type) {
55
+ case 'mouseDown':
56
+ case 'mouseUp':
57
+ case 'mouseMove': {
58
+ const { x, y, button, clickCount } = raw;
59
+ if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(clickCount))
60
+ return null;
61
+ if (typeof button !== 'string' || !BUTTONS.has(button))
62
+ return null;
63
+ return { type: raw.type, x, y, button: button, clickCount, modifiers };
64
+ }
65
+ case 'mouseWheel': {
66
+ const { x, y, deltaX, deltaY } = raw;
67
+ if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(deltaX) || !isFiniteNumber(deltaY))
68
+ return null;
69
+ return { type: 'mouseWheel', x, y, deltaX, deltaY, modifiers };
70
+ }
71
+ case 'keyDown':
72
+ case 'keyUp':
73
+ case 'char': {
74
+ const { keyCode } = raw;
75
+ if (typeof keyCode !== 'string')
76
+ return null;
77
+ return { type: raw.type, keyCode, modifiers };
78
+ }
79
+ default:
80
+ return null;
81
+ }
82
+ }
83
+ /**
84
+ * `setViewport`'s device scale factor. Real screens run 1x-3x; 4 leaves
85
+ * headroom without letting a renderer ask for an absurd raster. A missing
86
+ * value means 1 (the pre-mobile wire shape); anything else out of range is
87
+ * refused, never clamped — main must not guess at a malformed payload.
88
+ */
89
+ function parseDeviceScaleFactor(raw) {
90
+ if (raw === undefined)
91
+ return 1;
92
+ if (!isFiniteNumber(raw) || raw < 1 || raw > 4)
93
+ return null;
94
+ return raw;
95
+ }
96
+ /**
97
+ * Copies exactly the three known keys; the numbers must be finite and
98
+ * positive. A missing `agentControl` means false (the pre-live-drive wire
99
+ * shape); any non-boolean value is refused, never coerced.
100
+ */
101
+ function parseSettings(raw) {
102
+ if (!isRecord(raw))
103
+ return null;
104
+ const { hostDiagonalInches, hostNits } = raw;
105
+ if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0)
106
+ return null;
107
+ if (!isFiniteNumber(hostNits) || hostNits <= 0)
108
+ return null;
109
+ const agentControl = raw.agentControl ?? false;
110
+ if (typeof agentControl !== 'boolean')
111
+ return null;
112
+ return { hostDiagonalInches, hostNits, agentControl };
113
+ }
114
+ function parseMode(raw) {
115
+ return raw === 'url' || raw === 'image' ? raw : null;
116
+ }
117
+ /** Longest preset/profile id the UI-state mirror will store. */
118
+ const MAX_UI_ID = 64;
119
+ /**
120
+ * The renderer's UI-state report (`IPC.uiState`), mirrored main-side so the
121
+ * agent-control server can answer `status` without a renderer round-trip.
122
+ * Ids are copied as opaque strings (bounded — the mirror must not store an
123
+ * arbitrarily long one) rather than checked against the preset table: the
124
+ * report *describes* renderer state, and refusing an id main does not know
125
+ * would leave the mirror lying about it.
126
+ *
127
+ * `targetBounds` (the pane rect `captureTarget` crops to) is advisory:
128
+ * malformed or missing bounds become null — the capture falls back to the
129
+ * full window — rather than dropping the whole report and starving the
130
+ * mirror of the state it *is* sure about.
131
+ */
132
+ function parseUiState(raw) {
133
+ if (!isRecord(raw))
134
+ return null;
135
+ const { presetId, profileId, viewMode, mode } = raw;
136
+ if (typeof presetId !== 'string' || presetId.length === 0 || presetId.length > MAX_UI_ID)
137
+ return null;
138
+ if (typeof profileId !== 'string' || profileId.length === 0 || profileId.length > MAX_UI_ID)
139
+ return null;
140
+ if (viewMode !== '1:1' && viewMode !== 'fit')
141
+ return null;
142
+ if (mode !== 'url' && mode !== 'image')
143
+ return null;
144
+ return { presetId, profileId, viewMode, mode, targetBounds: parseRect(raw.targetBounds) };
145
+ }
146
+ /**
147
+ * A scroll offset reported by the sync preload in a page webContents. Both
148
+ * axes must be finite and non-negative; anything else is dropped rather than
149
+ * relayed to the other pane.
150
+ */
151
+ function parseScrollPos(raw) {
152
+ if (!isRecord(raw))
153
+ return null;
154
+ const { x, y } = raw;
155
+ if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0)
156
+ return null;
157
+ return { x, y };
158
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getobsrv",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "See your site the way 1x screens see it",
5
5
  "main": "./out/main/index.js",
6
6
  "bin": {
@@ -21,7 +21,8 @@
21
21
  "build:mcp": "tsc -p tsconfig.mcp.json",
22
22
  "prepublishOnly": "npm run build",
23
23
  "prepack": "node scripts/electron-dep.js to-prod",
24
- "postpack": "node scripts/electron-dep.js to-dev"
24
+ "postpack": "node scripts/electron-dep.js to-dev",
25
+ "release:pack": "npm run build && npm pack"
25
26
  },
26
27
  "dependencies": {
27
28
  "@fontsource/ibm-plex-mono": "^5.3.0",
@@ -50,8 +50,9 @@ If the obsrv MCP tools are connected (`obsrv_snap` / `obsrv_diff` /
50
50
  `obsrv_presets`), prefer them over shelling out — same pipeline, and the PNG
51
51
  comes back inline. If the Obsrv desktop app is open with "Agent control" on
52
52
  (toolbar toggle), snaps drive the visible window — the user watches — and
53
- `obsrv_drive` flips its URL/preset/profile directly; no app means the usual
54
- headless render.
53
+ `obsrv_drive` flips its URL/preset/profile directly, and can also scroll,
54
+ click, pan and highlight to walk the user through what it found; no app
55
+ means the usual headless render.
55
56
 
56
57
  ## The loop that catches real regressions
57
58