dsh-vision-router 1.7.6 → 1.7.7

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/entry.js CHANGED
@@ -12,7 +12,9 @@ import { installVisionRouterFileLogging } from './lib/file-logger.js'
12
12
  import { contextWithDelegatedReplay } from './lib/replay-delegation.js'
13
13
  import { contextWithReplayEnvelopeV2Compat } from './lib/replay-envelope-v2-compat.js'
14
14
  import { contextWithVisionExecutionPolicy } from './lib/vision-execution-policy.js'
15
+ import { contextWithVisionBackendRuntimePolicy } from './lib/vision-backend-runtime-policy.js'
15
16
  import { contextWithNativeImageCoexistence } from './lib/native-image-coexistence.js'
17
+ import { installLegacyCoreVisionPolicyBridge } from './lib/legacy-core-vision-policy-bridge.js'
16
18
  import { installPiAiBridgeWireCompat } from './lib/pi-ai-bridge-wire-compat.js'
17
19
  import { installLiveModelDiscovery } from './lib/live-model-discovery.js'
18
20
  import { installVisionModelRegistry } from './lib/vision-model-registry.js'
@@ -56,10 +58,13 @@ export const SETTINGS_CONTRACT_REVISION = 4
56
58
  // for the settings namespace, so composition config and settings validation
57
59
  // agree on the same default.
58
60
  core.Config.set('progressiveTools', z.boolean().default(false))
59
- // Structured 1+x also has a turn-level wall-clock budget. Individual
60
- // visionTaskTimeoutMs budgets remain unchanged; this one prevents a deep turn
61
- // from multiplying them into several minutes of serial waiting.
62
- core.Config.set('visionTurnBudgetMs', z.number().step(1000).min(10000).max(600000).default(90000))
61
+ // Keep the three timeout layers coherent: one provider call may use up to 120s,
62
+ // one visual task (including fallbacks) shares 120s, and one visual turn shares
63
+ // 120s. The runtime policy below reserves the final quarter of a multi-backend
64
+ // task for fallback, so raising the task ceiling does not revive the historical
65
+ // "120s per backend" stall that #117 removed.
66
+ core.Config.set('visionTaskTimeoutMs', z.number().step(1000).min(1000).max(180000).default(120000))
67
+ core.Config.set('visionTurnBudgetMs', z.number().step(1000).min(10000).max(600000).default(120000))
63
68
 
64
69
  // Both visible entry points — Settings > Vision Router and the legacy
65
70
  // Settings > Plugins compatibility card — edit the same Host-owned namespace.
@@ -113,7 +118,12 @@ export function apply(ctx, config = {}) {
113
118
  // route is registered. The wrapper patches only webServer.register and hands
114
119
  // every injection callback the original child context identity unchanged.
115
120
  const localMutationCtx = installLocalMutationRouteBoundary(ctx)
116
- const logging = installVisionRouterFileLogging(localMutationCtx)
121
+ // Normalize DSH 0.1.1's prepareCall contract at the deepest private LLM
122
+ // boundary. Higher Vision Router layers can finish wrapping adapter.stream
123
+ // before this boundary captures the final Host-visible adapter, so prepareCall
124
+ // cannot bypass local/runtime/replay stream behavior.
125
+ const adapterContractCtx = contextWithCoalescedAdapterUpdates(localMutationCtx)
126
+ const logging = installVisionRouterFileLogging(adapterContractCtx)
117
127
  const delegatedReplayCtx = contextWithDelegatedReplay(logging.ctx, {
118
128
  wrapperRoute:
119
129
  typeof config.wrapperRoute === 'string' && config.wrapperRoute !== ''
@@ -144,7 +154,7 @@ export function apply(ctx, config = {}) {
144
154
  // inference. Install this boundary before the local-vision stabilizer so the
145
155
  // final local vision-http adapter is observed after stabilization. Primary
146
156
  // local-Ollama image turns finish a cold model load in pre-step, before the
147
- // 45s vision-task budget begins; fallback Ollama warms in the background.
157
+ // visual task budget begins; fallback Ollama warms in the background.
148
158
  const ollamaColdStartCtx = installOllamaColdStartGuard(hardenedCtx, hardenedConfig, core)
149
159
  // #141 stabilization boundary: keep the recently merged local-vision
150
160
  // behavior isolated from main's existing provider/router semantics. It
@@ -160,10 +170,14 @@ export function apply(ctx, config = {}) {
160
170
  ...bootConfig,
161
171
  progressiveTools: hardenedConfig.progressiveTools === true,
162
172
  guidanceOverrides: normalizeGuidanceOverrides(bootConfig.guidanceOverrides ?? hardenedConfig.guidanceOverrides),
173
+ visionTaskTimeoutMs:
174
+ Number.isFinite(Number(bootConfig.visionTaskTimeoutMs))
175
+ ? Number(bootConfig.visionTaskTimeoutMs)
176
+ : 120000,
163
177
  visionTurnBudgetMs:
164
178
  Number.isFinite(Number(bootConfig.visionTurnBudgetMs))
165
179
  ? Number(bootConfig.visionTurnBudgetMs)
166
- : 90000,
180
+ : 120000,
167
181
  }
168
182
  // The batch-attachment API is the released, non-incidental discriminator
169
183
  // between the minimum Host contract and the newer Host-owned integration
@@ -204,23 +218,32 @@ export function apply(ctx, config = {}) {
204
218
  // so rc.7/rc.8's synthetic settings injection is visible to the boundary.
205
219
  // The secure screenshot renderer owns its exact FsTarget and active browser
206
220
  // cancellation directly, so it does not depend on this placement.
207
- const toolRuntimeCtx = installVisionToolRuntimeBoundary(attachmentCompatCtx)
221
+ const toolRuntimeCtx = installVisionToolRuntimeBoundary(attachmentCompatCtx, runtimeConfig)
208
222
  // DSH 0.1.1 publishes an exact native image-capable DeepSeek model. Do not
209
223
  // put it ahead of Vision Router's own configured chain: only when the user
210
224
  // has explicitly selected any Host-native image route, preserve raw pixels
211
- // and skip the hidden instant-local caption pass for that turn. The override
212
- // is AsyncLocalStorage-scoped and never mutates settings or provider order.
225
+ // and skip the hidden instant-local caption pass for that turn. Ownership is
226
+ // AsyncLocalStorage-scoped and never mutates settings or provider order.
213
227
  const nativeImageCompat = contextWithNativeImageCoexistence(toolRuntimeCtx, runtimeConfig)
228
+ // index.js still carries legacy global wrapper/tool gates. Feed the new
229
+ // session policy into that core through one narrow compatibility bridge:
230
+ // native/owned/unknown routes preserve raw pixels, text-only routes are
231
+ // normalized by the core's own rewriteHistoryImages implementation, and the
232
+ // boot-only tool projection ends immediately after core.apply wires schema.
233
+ const legacyCoreCompat = installLegacyCoreVisionPolicyBridge(
234
+ nativeImageCompat.ctx,
235
+ nativeImageCompat.config,
236
+ { rewriteHistoryImages: core.rewriteHistoryImages },
237
+ )
214
238
  // Final structured-flow guard sits closest to core.apply so it sees the
215
239
  // actual tool registrations and pre-step listener. It makes bootstrap
216
240
  // one-shot, enforces fast/standard/deep/custom quotas, tracks mixed branches,
217
241
  // rejects empty/non-evidence results, and applies one shared visual deadline.
218
- const structuredCtx = installStructuredFlowHardening(nativeImageCompat.ctx, nativeImageCompat.config)
219
- // Newer DSH releases publish llm/adapters-updated synchronously from inside
220
- // registerAdapter(). Coalesce only Vision Router's listener: nested events
221
- // mark the topology dirty and the outer pass reruns to a fixed point, so we
222
- // neither double-register a twin nor lose a provider added mid-pass.
223
- const reconciledCtx = contextWithCoalescedAdapterUpdates(structuredCtx)
242
+ const structuredCtx = installStructuredFlowHardening(legacyCoreCompat.ctx, legacyCoreCompat.config)
243
+ // Adapter reconciliation + prepareCall normalization are already installed at
244
+ // the final Host registration boundary above. Wrapping again here would make
245
+ // prepareCall capture a pre-wrapper stream.
246
+ const reconciledCtx = structuredCtx
224
247
  // Discover the provider's actual /models list independently of DSH's static
225
248
  // catalog. The Host owns credentials/networking/cache; the browser receives
226
249
  // model ids only. A live hit is also the evidence required before an
@@ -259,22 +282,27 @@ export function apply(ctx, config = {}) {
259
282
  // maxTokensField + route headers at its final fetch boundary. Ordinary DSH
260
283
  // streams and unrelated Vision Router HTTP providers remain byte-identical.
261
284
  installPiAiBridgeWireCompat(reconciledCtx, logging.logger)
262
- // Direct compatibility bridging is allowed only after DSH/pi-ai's exact
263
- // pre-wire image-capability admission rejection, or a local UNKNOWN_MODEL
264
- // backed by exact private-registry evidence. Record the same provenance in
265
- // the persistent diagnostics log so one image turn clearly shows attempt ->
266
- // adapter failure -> direct bridge -> success/fallback. Provider/network/auth
267
- // failures remain authoritative and cannot be retried through a second path.
285
+ // Existing execution policy stays authoritative for adapter-observed failures:
286
+ // only exact local pre-wire admission failures may unlock the post-failure
287
+ // bridge. The outer runtime policy added below handles the complementary case
288
+ // where DSH would silently project pixels to SHA text before the adapter ever
289
+ // gets a chance to reject them.
268
290
  const executionCtx = contextWithVisionExecutionPolicy(reconciledCtx, {
269
291
  isBridgeEvidence: (provider, model) => liveDiscovery.hasModel(provider, model),
270
292
  evidenceSource: (provider, model) => liveDiscovery.evidenceSource?.(provider, model),
271
293
  logger: logging.logger,
272
294
  })
295
+ const backendRuntimeCtx = contextWithVisionBackendRuntimePolicy(executionCtx, {
296
+ config: runtimeConfig,
297
+ core,
298
+ evidenceSource: (provider, model) => liveDiscovery.evidenceSource?.(provider, model),
299
+ logger: logging.logger,
300
+ })
273
301
  // The smoke-test route sends only a built-in probe image to the exact selected
274
302
  // backend. It never walks the configured fallback chain, so a healthy OVH
275
303
  // fallback can no longer make a broken custom model look healthy. Its narrow
276
304
  // compatibility bridge uses the same live-discovery evidence gate as runtime.
277
- installVisionBackendSmokeTest(executionCtx, runtimeConfig, core, {
305
+ installVisionBackendSmokeTest(backendRuntimeCtx, runtimeConfig, core, {
278
306
  logger: logging.logger,
279
307
  isBridgeEvidence: (provider, model) => liveDiscovery.hasModel(provider, model),
280
308
  })
@@ -283,7 +311,7 @@ export function apply(ctx, config = {}) {
283
311
  // for data until the OCR slice expires. Materialize only this exact
284
312
  // Tesseract-stdin call to a temporary image file; all other execFile calls
285
313
  // keep their native behavior.
286
- installTesseractExecFileCompat(executionCtx)
314
+ installTesseractExecFileCompat(backendRuntimeCtx)
287
315
 
288
316
  // 启动诊断摘要只描述 composition/apply 的基础配置。设置服务可能稍后
289
317
  // 覆盖这些值;每个图片轮还会记录 current() 的实时决策,避免把这个
@@ -304,7 +332,8 @@ export function apply(ctx, config = {}) {
304
332
  /* diagnostics must never break apply */
305
333
  }
306
334
  try {
307
- const result = core.apply(executionCtx, nativeImageCompat.config)
335
+ const result = core.apply(backendRuntimeCtx, legacyCoreCompat.config)
336
+ legacyCoreCompat.finishSchemaBootstrap()
308
337
  // On newer Hosts the Settings -> Models surface is backed by the
309
338
  // configurable-provider directory, not by the live adapter registry alone.
310
339
  // Publish the main DeepSeek + 自动识图 route as a derived alias of official
@@ -321,6 +350,7 @@ export function apply(ctx, config = {}) {
321
350
  }
322
351
  return result
323
352
  } catch (error) {
353
+ legacyCoreCompat.finishSchemaBootstrap()
324
354
  logging.logger.error(
325
355
  'vision-router: plugin apply failed: %s',
326
356
  error && error.stack ? error.stack : error && error.message ? error.message : String(error),
@@ -0,0 +1,274 @@
1
+ import {
2
+ IMAGE_OWNERSHIP,
3
+ currentSessionVisionPolicy,
4
+ } from './native-image-coexistence.js'
5
+ import { knownSessionVisionMemory } from './session-vision-state.js'
6
+
7
+ function isObject(value) {
8
+ return value !== null && typeof value === 'object'
9
+ }
10
+
11
+ function projectedConfig(value, state) {
12
+ if (!isObject(value) || Array.isArray(value)) return value
13
+ const policy = currentSessionVisionPolicy()
14
+ let changed = false
15
+ const overrides = {}
16
+
17
+ // index.js still constructs the vision-tool definitions behind a boot-time
18
+ // `if (toolEnabled())`. Keep that legacy construction gate open only while
19
+ // apply() is wiring the schema. The runtime boundary observes the unprojected
20
+ // Settings service and remains authoritative for live execution permission.
21
+ if (state.schemaBootstrapping && value.tool === false) {
22
+ overrides.tool = true
23
+ changed = true
24
+ }
25
+
26
+ // Session ownership is stronger evidence than index.js's historical global
27
+ // wrapperRegistered flag. A native, Vision-Router-owned, or metadata-unknown
28
+ // route must not have raw pixels destructively rewritten by the legacy core.
29
+ if (policy?.preserveRawImages === true && value.rewriteImages !== false) {
30
+ overrides.rewriteImages = false
31
+ changed = true
32
+ }
33
+
34
+ // Native multimodal routes already see the pixels. Avoid the hidden local
35
+ // caption pass and the generic tool auto-mount reminder, while leaving the
36
+ // explicit structured 1+x flow untouched.
37
+ if (policy?.ownership === IMAGE_OWNERSHIP.NATIVE) {
38
+ if (value.instantDescribe !== false) {
39
+ overrides.instantDescribe = false
40
+ changed = true
41
+ }
42
+ if (value.autoActivateOnImage !== false) {
43
+ overrides.autoActivateOnImage = false
44
+ changed = true
45
+ }
46
+ }
47
+
48
+ return changed ? { ...value, ...overrides } : value
49
+ }
50
+
51
+ function configView(config, state) {
52
+ if (!isObject(config)) return config
53
+ // Do not use the caller's config as the Proxy target. DSH or a test Host may
54
+ // freeze parsed config objects; projecting tool/rewrite values through a
55
+ // frozen target would violate Proxy invariants for non-configurable,
56
+ // non-writable properties. An empty extensible facade keeps projection
57
+ // virtual while writes/deletes still forward to the original object.
58
+ return new Proxy({}, {
59
+ get(_target, property) {
60
+ const projected = projectedConfig(config, state)
61
+ const value = Reflect.get(projected, property, projected)
62
+ return typeof value === 'function' ? value.bind(projected) : value
63
+ },
64
+ has(_target, property) {
65
+ return property in projectedConfig(config, state)
66
+ },
67
+ ownKeys() {
68
+ return Reflect.ownKeys(projectedConfig(config, state))
69
+ },
70
+ getOwnPropertyDescriptor(_target, property) {
71
+ const projected = projectedConfig(config, state)
72
+ const descriptor = Object.getOwnPropertyDescriptor(projected, property)
73
+ return descriptor === undefined
74
+ ? undefined
75
+ : { ...descriptor, configurable: true }
76
+ },
77
+ getPrototypeOf() {
78
+ return Reflect.getPrototypeOf(config)
79
+ },
80
+ set(_target, property, value) {
81
+ return Reflect.set(config, property, value, config)
82
+ },
83
+ deleteProperty(_target, property) {
84
+ return Reflect.deleteProperty(config, property)
85
+ },
86
+ })
87
+ }
88
+
89
+ function scopeView(scope, state) {
90
+ if (!isObject(scope)) return scope
91
+ return new Proxy(scope, {
92
+ get(target, property) {
93
+ if (property === 'get') {
94
+ const get = Reflect.get(target, property, target)
95
+ if (typeof get !== 'function') return get
96
+ return (...args) => projectedConfig(get.apply(target, args), state)
97
+ }
98
+ if (property === 'watch') {
99
+ const watch = Reflect.get(target, property, target)
100
+ if (typeof watch !== 'function') return watch
101
+ return (callback, ...rest) =>
102
+ watch.call(
103
+ target,
104
+ (...args) => {
105
+ if (typeof callback !== 'function') return undefined
106
+ if (args.length === 0) return callback()
107
+ const [value, ...tail] = args
108
+ return callback(projectedConfig(value, state), ...tail)
109
+ },
110
+ ...rest,
111
+ )
112
+ }
113
+ const value = Reflect.get(target, property, target)
114
+ return typeof value === 'function' ? value.bind(target) : value
115
+ },
116
+ })
117
+ }
118
+
119
+ function settingsView(settings, state) {
120
+ if (!isObject(settings)) return settings
121
+ return new Proxy(settings, {
122
+ get(target, property) {
123
+ if (property === 'get') {
124
+ const get = Reflect.get(target, property, target)
125
+ if (typeof get !== 'function') return get
126
+ return (namespace, ...args) => {
127
+ const value = get.call(target, namespace, ...args)
128
+ return namespace === 'vision-router' ? projectedConfig(value, state) : value
129
+ }
130
+ }
131
+ if (property === 'register') {
132
+ const register = Reflect.get(target, property, target)
133
+ if (typeof register !== 'function') return register
134
+ return (namespace, ...args) => {
135
+ const scope = register.call(target, namespace, ...args)
136
+ return namespace === 'vision-router' ? scopeView(scope, state) : scope
137
+ }
138
+ }
139
+ const value = Reflect.get(target, property, target)
140
+ return typeof value === 'function' ? value.bind(target) : value
141
+ },
142
+ })
143
+ }
144
+
145
+ function childContextView(child, state) {
146
+ if (!isObject(child)) return child
147
+ const settings = settingsView(child.settings, state)
148
+ return new Proxy(child, {
149
+ get(target, property) {
150
+ if (property === 'settings') return settings
151
+ const value = Reflect.get(target, property, target)
152
+ return typeof value === 'function' ? value.bind(target) : value
153
+ },
154
+ })
155
+ }
156
+
157
+ function rewriteTextOnlyDecision(payload, decision, rewriteHistoryImages) {
158
+ const policy = currentSessionVisionPolicy()
159
+ if (
160
+ decision?.kind === 'reject' ||
161
+ policy?.rewriteCurrentImages !== true ||
162
+ typeof rewriteHistoryImages !== 'function'
163
+ ) {
164
+ return decision
165
+ }
166
+
167
+ const source = Array.isArray(decision?.messages)
168
+ ? decision.messages
169
+ : Array.isArray(payload?.messages)
170
+ ? payload.messages
171
+ : undefined
172
+ if (!source) return decision
173
+
174
+ // Core registers the exact SessionMemoryView while processing this same
175
+ // pre-step. Reuse it here so a text-only fallback preserves cached visual
176
+ // descriptions instead of degrading them back to a generic attachment marker.
177
+ const memory = knownSessionVisionMemory(payload?.agent?.session)
178
+ const rewritten = rewriteHistoryImages(source, memory)
179
+ const messages = rewritten?.messages
180
+ if (!Array.isArray(messages) || messages === source) return decision
181
+ if (isObject(decision)) return { ...decision, messages }
182
+ return { kind: 'continue', messages }
183
+ }
184
+
185
+ /**
186
+ * Adapt the session-scoped ownership policy to the legacy monolithic core.
187
+ *
188
+ * The policy layer remains read-only. This bridge only projects legacy config
189
+ * reads while one pre-step is active and post-processes an explicitly text-only
190
+ * decision through index.js's exported rewriteHistoryImages implementation and
191
+ * the exact SessionMemoryView registered by core for that pre-step. No marker,
192
+ * rewrite algorithm, or cross-session image state is duplicated here.
193
+ */
194
+ export function installLegacyCoreVisionPolicyBridge(
195
+ ctx,
196
+ config = {},
197
+ { rewriteHistoryImages } = {},
198
+ ) {
199
+ if (!isObject(ctx)) {
200
+ return {
201
+ ctx,
202
+ config,
203
+ finishSchemaBootstrap() {},
204
+ }
205
+ }
206
+
207
+ const state = { schemaBootstrapping: true }
208
+ const projectedBootConfig = configView(config, state)
209
+ const settingsCache = new WeakMap()
210
+
211
+ const wrapSettings = (settings) => {
212
+ if (!isObject(settings)) return settings
213
+ const cached = settingsCache.get(settings)
214
+ if (cached) return cached
215
+ const wrapped = settingsView(settings, state)
216
+ settingsCache.set(settings, wrapped)
217
+ return wrapped
218
+ }
219
+
220
+ const wrappedCtx = new Proxy(ctx, {
221
+ get(target, property) {
222
+ if (property === 'get') {
223
+ const get = Reflect.get(target, property, target)
224
+ if (typeof get !== 'function') return get
225
+ return (name, ...args) => {
226
+ const value = get.call(target, name, ...args)
227
+ return name === 'settings' ? wrapSettings(value) : value
228
+ }
229
+ }
230
+ if (property === 'inject') {
231
+ const inject = Reflect.get(target, property, target)
232
+ if (typeof inject !== 'function') return inject
233
+ return (dependencies, callback, ...rest) => {
234
+ if (
235
+ !Array.isArray(dependencies) ||
236
+ !dependencies.includes('settings') ||
237
+ typeof callback !== 'function'
238
+ ) {
239
+ return inject.call(target, dependencies, callback, ...rest)
240
+ }
241
+ return inject.call(
242
+ target,
243
+ dependencies,
244
+ (child) => callback(childContextView(child, state)),
245
+ ...rest,
246
+ )
247
+ }
248
+ }
249
+ if (property === 'on') {
250
+ const on = Reflect.get(target, property, target)
251
+ if (typeof on !== 'function') return on
252
+ return (event, handler, ...rest) => {
253
+ if (event !== 'agent/pre-step' || typeof handler !== 'function') {
254
+ return on.call(target, event, handler, ...rest)
255
+ }
256
+ return on.call(target, event, async function legacyCoreVisionPolicyPreStep(payload, next) {
257
+ const decision = await handler.call(this, payload, next)
258
+ return rewriteTextOnlyDecision(payload, decision, rewriteHistoryImages)
259
+ }, ...rest)
260
+ }
261
+ }
262
+ const value = Reflect.get(target, property, target)
263
+ return typeof value === 'function' ? value.bind(target) : value
264
+ },
265
+ })
266
+
267
+ return {
268
+ ctx: wrappedCtx,
269
+ config: projectedBootConfig,
270
+ finishSchemaBootstrap() {
271
+ state.schemaBootstrapping = false
272
+ },
273
+ }
274
+ }
@@ -100,7 +100,11 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
100
100
 
101
101
  const syncScreenshot = () => {
102
102
  if (!screenshotCandidate || !rawTools || typeof rawTools.register !== 'function') return
103
- if (actualConfig().desktopScreenshot !== true) {
103
+ const current = actualConfig()
104
+ // desktopScreenshot owns schema exposure. The global tool toggle is a live
105
+ // execution permission enforced by the runtime boundary (and the captured
106
+ // execute guard below), so flipping it must not churn the tool schema.
107
+ if (current.desktopScreenshot !== true) {
104
108
  unmountScreenshot()
105
109
  return
106
110
  }
@@ -125,14 +129,28 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
125
129
  }
126
130
  return (def) => {
127
131
  if (def && def.name === 'vision_screenshot') {
128
- screenshotCandidate = def
132
+ const candidate =
133
+ typeof def.execute === 'function'
134
+ ? {
135
+ ...def,
136
+ async execute(args, exec) {
137
+ if (actualConfig().tool === false) {
138
+ throw new Error(
139
+ 'vision_screenshot: vision tools are disabled in the Vision Router settings',
140
+ )
141
+ }
142
+ return def.execute.call(this, args, exec)
143
+ },
144
+ }
145
+ : def
146
+ screenshotCandidate = candidate
129
147
  syncScreenshot()
130
148
  let active = true
131
149
  return () => {
132
150
  if (!active) return
133
151
  active = false
134
- if (screenshotCandidate === def) screenshotCandidate = undefined
135
- if (screenshotMountedDef === def) unmountScreenshot()
152
+ if (screenshotCandidate === candidate) screenshotCandidate = undefined
153
+ if (screenshotMountedDef === candidate) unmountScreenshot()
136
154
  }
137
155
  }
138
156
  return target.register(def)
@@ -393,7 +411,13 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
393
411
  res.end(JSON.stringify({ ok: false, error: 'cross-origin screenshot permission request rejected' }))
394
412
  return
395
413
  }
396
- if (actualConfig().desktopScreenshot !== true) {
414
+ const current = actualConfig()
415
+ if (current.tool === false) {
416
+ res.writeHead(409, { 'content-type': 'application/json' })
417
+ res.end(JSON.stringify({ ok: false, error: 'vision tools are disabled' }))
418
+ return
419
+ }
420
+ if (current.desktopScreenshot !== true) {
397
421
  res.writeHead(409, { 'content-type': 'application/json' })
398
422
  res.end(JSON.stringify({ ok: false, error: 'desktop screenshot is disabled' }))
399
423
  return
@@ -493,4 +517,4 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
493
517
  })
494
518
 
495
519
  return { ctx: stabilizedCtx, bootConfig }
496
- }
520
+ }