dsh-vision-router 2.2.5 → 2.2.6

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
@@ -42,9 +42,9 @@
42
42
  > **Data leaves your machine by default.** When a cloud vision model is used, Vision Router sends the image (or a derived crop), the vision prompt and related request metadata to that provider. A fresh install has an anonymous OVHcloud vision fallback enabled, so "free" and "no key" do **not** mean offline. Do not use the cloud chain for confidential, regulated or classified material. See [Data flow and strict local-only use](#data-flow-and-strict-local-only-use).
43
43
 
44
44
  > [!WARNING]
45
- > 📌 **Announcement (v2.2.1)**
45
+ > 📌 **Announcement (v2.2.5)**
46
46
  >
47
- > **v2.2.1:** Stability hotfix for large-session repair, accessor-based fetch composition, non-divisible pixel-diff bounds, and local Ollama answer-budget preservation — without raising the rc.8 Host floor. [What’s new →](docs/releases/v2.2.1.md)
47
+ > **v2.2.5:** Adds fail-closed **Local-only vision** and fixes the remaining DSH `0.1.7-rc.2` Windows Desktop multi-plugin `Loading plugins…` hang by isolating Vision Router’s WebServer registrar from other plugins. The public Host floor remains `0.1.0-rc.8`. [What’s new →](docs/releases/v2.2.5.md)
48
48
 
49
49
  <p align="center">
50
50
  <img src="assets/vision-demo.gif" width="640" alt="Demo: paste an image, the agent locates the send button with vision_ground / vision_crop / vision_pixel_diff and answers with coordinates" />
@@ -436,7 +436,7 @@ ollama pull qwen2.5vl
436
436
  ## Requirements
437
437
 
438
438
  - DeepSeek Harness Web profile. Normal installs can use `npx @deepseek-ai/dsh ...`; source checkouts use `pnpm dsh ...`. A bare `dsh ...` command only works when the CLI is already on your shell `PATH`.
439
- - **DSH Host support policy:** DVR 2.2.x keeps DSH `0.1.0-rc.8` as the public minimum and currently supports the released stable channel through `0.1.5-rc.3`. Exact `0.1.7-rc.2` (`next`) coverage is **verification evidence only**, not a preview support promise; scheduled `latest`/`alpha` canaries monitor drift without changing the support policy. DVR 2.0.x was the final train with public support for rc.6/rc.7. See [DSH Host support window](docs/architecture/dsh-support-window.md).
439
+ - **DSH Host support policy:** DVR 2.2.x keeps DSH `0.1.0-rc.8` as the public minimum and currently supports the released stable channel through `0.1.5-rc.3`. The DSH `0.2.x` train is forward-admitted (`^0.2.0`) after exact pre-release master, real Host, Windows Desktop, multi-plugin isolation, and packaged-ASAR validation; the final immutable `0.2.0` tag and signed Desktop artifact will be revalidated when upstream publishes them. Exact `0.1.7-rc.2` (`next`) coverage remains verification evidence. DVR 2.0.x was the final train with public support for rc.6/rc.7. See [DSH Host support window](docs/architecture/dsh-support-window.md).
440
440
  - Node ≥ 22 (host side).
441
441
  - No API key for the default free chain; a credential reference (`apiKeyEnv`) only for paid `httpProviders`.
442
442
  - Chrome / Chromium / Edge is needed only for `vision_html_screenshot`; every other tool works without a browser.
package/README.zh.md CHANGED
@@ -42,9 +42,9 @@
42
42
  > **默认配置会让数据出网。** 使用云端视觉模型时,Vision Router 会将图片(或由它裁剪出的局部)、识图提示词及相关请求元数据发送给对应供应商。新安装默认开启 OVHcloud 匿名视觉兜底,因此“免费”和“免 Key”不等于离线。涉密、涉案、内部或其他受限材料请勿使用云端链路。详见[数据流向与严格纯本地配置](#数据流向与严格纯本地配置)。
43
43
 
44
44
  > [!WARNING]
45
- > 📌 **公告(v2.2.1)**
45
+ > 📌 **公告(v2.2.5)**
46
46
  >
47
- > **v2.2.1:稳定性热修——修复大型 Session repair 卡死、accessor fetch 组合、非整除 pixel diff 边界与本地 Ollama 推理占满答案预算;公开最低 Host 仍保持 `0.1.0-rc.8`。** [查看完整更新 →](docs/releases/v2.2.1.md)
47
+ > **v2.2.5:新增 fail-closed 的「仅本地视觉」,并根修 DSH `0.1.7-rc.2` Windows Desktop 多插件环境下残留的 `Loading plugins…` 卡死——Vision Router 的 WebServer registrar 不再污染其他插件。公开最低 Host 仍保持 `0.1.0-rc.8`。** [查看完整更新 →](docs/releases/v2.2.5.md)
48
48
 
49
49
  <p align="center">
50
50
  <img src="assets/vision-demo.gif" width="640" alt="演示:粘贴图片,Agent 用 vision_ground / vision_crop / vision_pixel_diff 定位发送按钮并给出坐标" />
@@ -434,7 +434,7 @@ ollama pull qwen2.5vl
434
434
  ## 环境要求
435
435
 
436
436
  - DeepSeek Harness 的 Web profile。普通安装可用 `npx @deepseek-ai/dsh ...`;从源码仓库运行时用 `pnpm dsh ...`。只有 CLI 已经进入系统 `PATH` 时才能直接写 `dsh ...`。
437
- - **DSH Host 支持策略:** DVR 2.2.x 的公开最低 Host 仍为 DSH `0.1.0-rc.8`,当前正式发布通道已验证并支持到 `0.1.5-rc.3`。对 `0.1.7-rc.2`(`next`)的精确覆盖**只属于验证证据**,不代表对 preview 的公开支持承诺;定时 `latest`/`alpha` canary 只负责发现上游漂移,也不会自动改变支持策略。DVR 2.0.x 是最后公开支持 rc.6/rc.7 的版本线。详见 [DSH Host 支持窗口](docs/architecture/dsh-support-window.md)。
437
+ - **DSH Host 支持策略:** DVR 2.2.x 的公开最低 Host 仍为 DSH `0.1.0-rc.8`,当前正式发布通道已验证并支持到 `0.1.5-rc.3`。DSH `0.2.x` 版本线已在完成精确预发布 master、真实 Host、Windows Desktop、多插件隔离及打包后 ASAR 验证后提前准入(`^0.2.0`);上游正式发布不可变 `0.2.0` tag 和签名 Desktop 安装包后仍会立即复验。对 `0.1.7-rc.2`(`next`)的精确覆盖继续作为验证证据。DVR 2.0.x 是最后公开支持 rc.6/rc.7 的版本线。详见 [DSH Host 支持窗口](docs/architecture/dsh-support-window.md)。
438
438
  - Node ≥ 22(宿主侧)。
439
439
  - 默认免费链路无需 API Key;付费 `httpProviders` 只需一个凭据引用(`apiKeyEnv`)。
440
440
  - 只有 `vision_html_screenshot` 需要 Chrome / Chromium / Edge;其余工具无浏览器也能用。
@@ -17,6 +17,12 @@ DSH `0.1.5-rc.1` / `0.1.5-rc.2` remain admitted for existing installations; curr
17
17
 
18
18
  No later support-floor increase is currently announced.
19
19
 
20
+ ### DSH 0.2.x forward admission
21
+
22
+ DVR 2.2.x peer-admits the DSH `0.2.x` train (`^0.2.0`) without raising the `0.1.0-rc.8` minimum Host floor. This forward admission is backed by the public pre-0.2.0 master at `21638c56315ae6a2b552d6091945d3144c9af32e`: three-OS source contracts, real Host + Chromium, Windows Node 22/24 Desktop authentication, the Node 24 multi-plugin isolation adversary, and an unsigned Windows Desktop built through the upstream release packager all passed. The packaged audit also exercised DVR from `resources/app.asar/dsh`, including authenticated index/API requests, DVR RPC, structured bootstrap injection, and foreign WebServer registrar isolation.
23
+
24
+ The immutable upstream `0.2.0` tag and signed official Desktop installer did not yet exist when this admission was added. They remain release-time revalidation targets; this forward admission prevents DSH's Host peer gate from rejecting otherwise-compatible DVR installs during that release transition.
25
+
20
26
  ## Verification evidence — not support policy
21
27
 
22
28
  Compatibility evidence answers a different question: what exact upstream releases and moving channels have current CI proof? It must never be interpreted as a public support-floor change.
@@ -0,0 +1,24 @@
1
+ # v2.2.6
2
+
3
+ DSH Vision Router 2.2.6 is a DSH 0.2 compatibility and runtime-hardening update. It admits the validated DSH 0.2 release train without raising the public Host floor, reduces Settings startup amplification, adds the LM Studio native chat route, and bounds Doctor probe responses while streaming.
4
+
5
+ ## Highlights
6
+
7
+ - **DSH 0.2 release-train compatibility:** both official DSH peers now admit `>=0.2.0-rc.1 <0.3.0-0`. This covers validated `0.2.0-rc.1` and later 0.2.x prerelease/stable versions without requiring a peer edit for every tag, while every 0.3 prerelease/stable remains excluded until a separate compatibility review.
8
+ - **Permanent DSH `0.2.0-rc.1` gates:** exact-source Windows/macOS/Linux checks, real Host + Chromium lifecycle coverage, Windows Desktop Node 22/24 authenticated-request checks, Node 24 multi-plugin ordering, and stable/next/preview release-channel canaries are now part of CI.
9
+ - **Settings startup amplification root fix (#577):** the DSH 0.1.7 Settings compatibility layer reuses a stable shared service/cache/listener/mutation queue behind the per-caller facade instead of multiplying full-profile recomposition work across child contexts. Exact `0.1.7-rc.2` multi-plugin measurements dropped from 59 configuration calls to roughly 8–10 in the tested profile. This addresses the second amplification mechanism found while investigating #551; the reporter's field A/B remains open and is not treated as closed by this release.
10
+ - **LM Studio native route (#579):** native LM Studio requests use `/api/v1/chat`, and the plugin maps disabled reasoning to the native `reasoning: "off"` behavior instead of sending an OpenAI-compatible-only field.
11
+ - **Doctor response hardening (#580):** runtime probes enforce the response ceiling while streaming and cancel oversized responses instead of buffering beyond the diagnostic boundary.
12
+ - Keeps the public minimum supported DSH Host at `0.1.0-rc.8`; no settings migration is required.
13
+
14
+ ## Validation
15
+
16
+ - Exact DSH `0.2.0-rc.1@4878cdabd87d4041bdaff61d04c966883b9fd07a` passes Ubuntu/macOS/Windows source contracts, official Host peer evaluation, proxy/overlay contracts, real Host + Chromium Vision/mixed-attachment/Settings lifecycles, Windows Desktop Node 22/24, and the Node 24 multi-plugin adversary.
17
+ - The retained DSH `0.1.7-rc.2` real-Host/browser regression gates remain green.
18
+ - A physical macOS run using the published npm runtime `@deepseek-ai/dsh@0.2.0-rc.1` passed plugin admission, authentication boundaries, Settings save/reload/readback, enabled Vision composer flow, mixed attachment paste/upload, and three consecutive Host restarts with persisted settings and no compatibility-disable/uncaught/fatal signatures.
19
+ - Main CI, DSH Contract, Adversarial Fuzz, routing parity, dependency review, preview browser smoke, native multimodal cold-resume, and the DSH release-channel canary passed on the final compatibility head.
20
+ - Local release-prep suite: `1507` passed, `1` intentional skip, `0` failed; the separate backend runtime-policy suite passed `9/9`.
21
+
22
+ ## Upgrade
23
+
24
+ Users moving to DSH `0.2.0-rc.1` should upgrade to Vision Router 2.2.6 so the Host compatibility preflight accepts the plugin. Existing supported 0.1.x users can also upgrade in place. Issue #551 remains open for the reporter's original `0.1.7-rc.2` 18-plugin field A/B and is tracked independently from this release.
package/index.js CHANGED
@@ -1164,8 +1164,9 @@ export function apply(ctx, config = {}, runtime = {}) {
1164
1164
  // Local backends (format=anthropic etc.) go through their own
1165
1165
  // dispatcher; regular HTTP providers keep the plain OpenAI call.
1166
1166
  // Both consume the same pre-built OpenAI content blocks (image_url
1167
- // data URIs); callLocalBackend converts them for the Anthropic wire.
1168
- text = await (entry.provider.format === 'anthropic'
1167
+ // data URIs); callLocalBackend converts them for the Anthropic and
1168
+ // LM Studio native wires.
1169
+ text = await ((entry.provider.format === 'anthropic' || entry.provider.format === 'lmstudio')
1169
1170
  ? callLocalBackend(entry.provider, openAIMessages, {
1170
1171
  maxTokens: entry.provider.maxTokens ?? 4096,
1171
1172
  signal: options.signal,
@@ -7,6 +7,7 @@ import {
7
7
  formatDshHostCapability,
8
8
  normalizeDshHostCapabilities,
9
9
  } from './dsh-host-capabilities.js'
10
+ import { DOCTOR_RESPONSE_MAX_BYTES, readResponseJsonBounded } from './http-body-limit.js'
10
11
  import {
11
12
  DSH_SUPPORT_WINDOW,
12
13
  DSH_VERIFICATION_EVIDENCE,
@@ -63,7 +64,9 @@ export async function probeDoctorHostCapabilities({ baseUrl, fetchImpl = globalT
63
64
  capabilities: unknownSnapshot(),
64
65
  }
65
66
  }
66
- const body = await response.json()
67
+ const body = await readResponseJsonBounded(response, DOCTOR_RESPONSE_MAX_BYTES, {
68
+ label: 'Doctor Host capability response',
69
+ })
67
70
  return {
68
71
  ok: true,
69
72
  source: 'live-runtime',
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import os, { homedir } from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { CURRENT_VERSION } from './update-check.js'
6
+ import { DOCTOR_RESPONSE_MAX_BYTES, readResponseTextBounded } from './http-body-limit.js'
6
7
 
7
8
  export const DEFAULT_RUNTIME_URL = 'http://127.0.0.1:3080'
8
9
  const SAFE_RUNTIME_ROUTES = [
@@ -111,17 +112,17 @@ export async function probeRuntime({
111
112
  signal: timeoutSignal(timeoutMs),
112
113
  })
113
114
  if (response.status === 200) {
114
- const text = typeof response.text === 'function' ? await response.text() : ''
115
- if (Buffer.byteLength(text) <= 16 * 1024) {
116
- const body = text ? JSON.parse(text) : undefined
117
- const expectedDirectory = path.resolve(dshHome, 'logs', 'vision-router')
118
- const actualDirectory = typeof body?.directory === 'string' ? path.resolve(body.directory) : undefined
119
- const candidates = [...new Set(applicableProfiles.filter((name) => typeof name === 'string'))]
120
- if (body?.local === true && actualDirectory === expectedDirectory && candidates.length === 1 && candidates[0] === requestedProfile) {
121
- ownership = { requestedProfile, verified: true, profile: requestedProfile, source: 'log-home+unique-profile' }
122
- } else if (body?.local === true && actualDirectory === expectedDirectory) {
123
- ownership = { requestedProfile, verified: false, reason: 'profile-ambiguous', candidateProfiles: candidates }
124
- }
115
+ const text = await readResponseTextBounded(response, DOCTOR_RESPONSE_MAX_BYTES, {
116
+ label: 'Doctor runtime ownership response',
117
+ })
118
+ const body = text ? JSON.parse(text) : undefined
119
+ const expectedDirectory = path.resolve(dshHome, 'logs', 'vision-router')
120
+ const actualDirectory = typeof body?.directory === 'string' ? path.resolve(body.directory) : undefined
121
+ const candidates = [...new Set(applicableProfiles.filter((name) => typeof name === 'string'))]
122
+ if (body?.local === true && actualDirectory === expectedDirectory && candidates.length === 1 && candidates[0] === requestedProfile) {
123
+ ownership = { requestedProfile, verified: true, profile: requestedProfile, source: 'log-home+unique-profile' }
124
+ } else if (body?.local === true && actualDirectory === expectedDirectory) {
125
+ ownership = { requestedProfile, verified: false, reason: 'profile-ambiguous', candidateProfiles: candidates }
125
126
  }
126
127
  }
127
128
  } catch {
@@ -110,6 +110,18 @@ function revisionFor(editor, namespace, current, inherited) {
110
110
  return previous.revision
111
111
  }
112
112
 
113
+ const CORDIS_ORIGINAL = Symbol.for('cordis.original')
114
+
115
+ function serviceIdentity(value) {
116
+ if (!value || (typeof value !== 'object' && typeof value !== 'function')) return value
117
+ try {
118
+ const original = value[CORDIS_ORIGINAL]
119
+ return original && (typeof original === 'object' || typeof original === 'function') ? original : value
120
+ } catch {
121
+ return value
122
+ }
123
+ }
124
+
113
125
  function serviceOf(ctx, name) {
114
126
  try {
115
127
  const value = typeof ctx?.get === 'function' ? ctx.get(name) : undefined
@@ -176,17 +188,26 @@ function applyOperations(current, inherited, ops) {
176
188
  return next
177
189
  }
178
190
 
179
- function createConfigEditorSettingsFacade(editor, entryConfig, namespace) {
180
- const listeners = new Set()
191
+ function createConfigEditorSettingsFacade(editor, entryConfig, namespace, sharedState) {
192
+ const listeners = sharedState.listeners
181
193
 
182
- function state() {
194
+ // ConfigEditor.configuration() rebuilds the complete profile and, on 0.1.7,
195
+ // composes every entry through structuredClone(). Ordinary profile writes
196
+ // reload this plugin and therefore replace the facade; keep one immutable
197
+ // instance snapshot for runtime reads and refresh only at write boundaries.
198
+ function state(refresh = false) {
199
+ if (sharedState.stateLoaded && !refresh) return sharedState.cachedState
183
200
  const row = configEditorRow(editor, namespace)
184
- if (!row) return undefined
201
+ if (!row) {
202
+ sharedState.cachedState = undefined
203
+ sharedState.stateLoaded = true
204
+ return sharedState.cachedState
205
+ }
185
206
  const current = objectLike(row.entry?.options?.config) ? row.entry.options.config : {}
186
207
  const inherited = objectLike(row.inherited) ? row.inherited : {}
187
208
  const base = mergeResolved(entryConfig, inherited)
188
209
  const value = mergeResolved(entryConfig, current)
189
- return {
210
+ sharedState.cachedState = {
190
211
  row,
191
212
  descriptor: {
192
213
  ns: namespace,
@@ -197,17 +218,25 @@ function createConfigEditorSettingsFacade(editor, entryConfig, namespace) {
197
218
  applies: true,
198
219
  },
199
220
  }
221
+ sharedState.stateLoaded = true
222
+ return sharedState.cachedState
200
223
  }
201
224
 
202
225
  function notify(value) {
203
226
  for (const listener of [...listeners]) {
204
- try { listener(value, undefined) } catch {}
227
+ try { listener(clone(value), undefined) } catch {}
205
228
  }
206
229
  }
207
230
 
231
+ function enqueueMutation(task) {
232
+ const running = sharedState.mutationTail.then(task, task)
233
+ sharedState.mutationTail = running.catch(() => {})
234
+ return running
235
+ }
236
+
208
237
  const scope = {
209
238
  get() {
210
- return state()?.descriptor.value ?? entryConfig
239
+ return clone(state()?.descriptor.value ?? entryConfig)
211
240
  },
212
241
  watch(listener) {
213
242
  if (typeof listener !== 'function') return () => {}
@@ -234,23 +263,35 @@ function createConfigEditorSettingsFacade(editor, entryConfig, namespace) {
234
263
  const current = state()
235
264
  return current ? [clone(current.descriptor)] : []
236
265
  },
237
- async mutate(namespaceName, operations, expectedRevision) {
238
- if (namespaceName !== namespace) throw new Error(`No configurable plugin entry ${JSON.stringify(namespaceName)}`)
239
- const before = state()
240
- if (!before) throw new Error(`No configurable plugin entry ${JSON.stringify(namespace)}`)
241
- if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
242
- throw admissionError('expectedRevision must be a non-negative safe integer')
243
- }
244
- if (expectedRevision !== before.descriptor.revision) {
245
- throw settingsConflict(expectedRevision, before.descriptor.revision)
266
+ mutate(namespaceName, operations, expectedRevision) {
267
+ let ops
268
+ try {
269
+ if (namespaceName !== namespace) throw new Error(`No configurable plugin entry ${JSON.stringify(namespaceName)}`)
270
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
271
+ throw admissionError('expectedRevision must be a non-negative safe integer')
272
+ }
273
+ // Validate and clone caller-owned values before this write waits behind
274
+ // another mutation, closing a queued-request time-of-check/use gap.
275
+ ops = validateOperations(operations)
276
+ } catch (error) {
277
+ return Promise.reject(error)
246
278
  }
247
- const ops = validateOperations(operations)
248
- const entry = before.row.entry
249
- await editor.edit(entry, (current, inherited) => applyOperations(current, inherited, ops))
250
- const after = state()
251
- if (!after) throw new Error('Vision Router configuration entry disappeared after the write')
252
- notify(after.descriptor.value)
253
- return clone(after.descriptor)
279
+ return enqueueMutation(async () => {
280
+ // Runtime reads use the facade snapshot. Writes deliberately cross the
281
+ // ConfigEditor boundary again so optimistic concurrency still observes
282
+ // changes that landed before a Host reload replaced this facade.
283
+ const before = state(true)
284
+ if (!before) throw new Error(`No configurable plugin entry ${JSON.stringify(namespace)}`)
285
+ if (expectedRevision !== before.descriptor.revision) {
286
+ throw settingsConflict(expectedRevision, before.descriptor.revision)
287
+ }
288
+ const entry = before.row.entry
289
+ await editor.edit(entry, (current, inherited) => applyOperations(current, inherited, ops))
290
+ const after = state(true)
291
+ if (!after) throw new Error('Vision Router configuration entry disappeared after the write')
292
+ notify(after.descriptor.value)
293
+ return clone(after.descriptor)
294
+ })
254
295
  },
255
296
  }
256
297
  }
@@ -264,6 +305,7 @@ function createConfigEditorSettingsFacade(editor, entryConfig, namespace) {
264
305
  function contextWithLazySettingsCompatibility(ctx, entryConfig, namespace) {
265
306
  const wrappers = new WeakMap()
266
307
  const facades = new WeakMap()
308
+ const sharedStates = new WeakMap()
267
309
  let injectedEditor
268
310
 
269
311
  try {
@@ -284,9 +326,24 @@ function contextWithLazySettingsCompatibility(ctx, entryConfig, namespace) {
284
326
  || !editor || typeof editor.edit !== 'function' || typeof editor.configuration !== 'function') {
285
327
  return settings
286
328
  }
329
+ const identity = serviceIdentity(editor)
330
+ let sharedState = sharedStates.get(identity)
331
+ if (!sharedState) {
332
+ sharedState = {
333
+ listeners: new Set(),
334
+ cachedState: undefined,
335
+ stateLoaded: false,
336
+ mutationTail: Promise.resolve(),
337
+ }
338
+ sharedStates.set(identity, sharedState)
339
+ }
287
340
  let facade = facades.get(editor)
288
341
  if (!facade) {
289
- facade = createConfigEditorSettingsFacade(editor, entryConfig, namespace)
342
+ // Cordis may hand different traceable proxy views of the same service to
343
+ // different child contexts. Keep each facade bound to its caller-owned
344
+ // proxy for method ownership, but share the expensive immutable snapshot
345
+ // and write queue by the stable underlying service identity.
346
+ facade = createConfigEditorSettingsFacade(editor, entryConfig, namespace, sharedState)
290
347
  facades.set(editor, facade)
291
348
  }
292
349
  return facade
@@ -3,6 +3,7 @@ const DEFAULT_FALLBACK_MAX_BYTES = 4 * 1024 * 1024
3
3
  export const MODEL_RESPONSE_MAX_BYTES = 4 * 1024 * 1024
4
4
  export const METADATA_RESPONSE_MAX_BYTES = 512 * 1024
5
5
  export const ERROR_RESPONSE_MAX_BYTES = 64 * 1024
6
+ export const DOCTOR_RESPONSE_MAX_BYTES = 16 * 1024
6
7
 
7
8
  function normalizedLimit(value) {
8
9
  const number = Number(value)
@@ -34,7 +35,10 @@ export async function readResponseBytesBounded(response, maxBytes, options = {})
34
35
  const limit = normalizedLimit(maxBytes)
35
36
  const label = typeof options.label === 'string' && options.label !== '' ? options.label : 'HTTP response'
36
37
  const declared = declaredLength(response)
37
- if (declared !== undefined && declared > limit) throw responseTooLarge(label, limit, declared)
38
+ if (declared !== undefined && declared > limit) {
39
+ try { await response?.body?.cancel?.('response body limit exceeded') } catch { /* best effort */ }
40
+ throw responseTooLarge(label, limit, declared)
41
+ }
38
42
 
39
43
  const body = response?.body
40
44
  if (body && typeof body.getReader === 'function') {
@@ -63,15 +63,25 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
63
63
  const positive = (value, fallback) =>
64
64
  Number.isFinite(value) && value > 0 ? Number(value) : fallback
65
65
 
66
- const actualConfig = () => {
66
+ let resolvedConfig = config
67
+
68
+ const captureConfig = (value, scope = rawScope) => {
67
69
  try {
68
- const value = rawScope && typeof rawScope.get === 'function' ? rawScope.get() : config
69
- return normalizeRuntimeVisionConfig(value && typeof value === 'object' ? value : config)
70
+ let candidate = value
71
+ if ((!candidate || typeof candidate !== 'object') && scope && typeof scope.get === 'function') {
72
+ candidate = scope.get()
73
+ }
74
+ resolvedConfig = normalizeRuntimeVisionConfig(
75
+ candidate && typeof candidate === 'object' ? candidate : config,
76
+ )
70
77
  } catch {
71
- return config
78
+ resolvedConfig = config
72
79
  }
80
+ return resolvedConfig
73
81
  }
74
82
 
83
+ const actualConfig = () => resolvedConfig
84
+
75
85
  const localTaskBudget = (value = actualConfig()) =>
76
86
  Math.max(
77
87
  1000,
@@ -344,6 +354,7 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
344
354
  if (typeof watch !== 'function') return watch
345
355
  return (listener) =>
346
356
  watch.call(target, (...args) => {
357
+ if (rawScope === target) captureConfig(args[0], target)
347
358
  syncScreenshot()
348
359
  return listener(...args)
349
360
  })
@@ -369,6 +380,7 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
369
380
  const scope = target.register(namespace, schema, fixedOptions)
370
381
  if (namespace === 'vision-router') {
371
382
  rawScope = scope
383
+ captureConfig(undefined, scope)
372
384
  syncScreenshot()
373
385
  // The Settings service is dynamic. Never keep its scope after the
374
386
  // owning injection fiber unloads; during the gap fall back to the
@@ -378,6 +390,7 @@ export function installLocalVisionStabilizer(ctx, config = {}, core) {
378
390
  () => () => {
379
391
  if (rawScope !== scope) return
380
392
  rawScope = undefined
393
+ resolvedConfig = config
381
394
  syncScreenshot()
382
395
  },
383
396
  'vision-router: local settings scope lifecycle',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-router",
3
- "version": "2.2.5",
3
+ "version": "2.2.6",
4
4
  "description": "Eyes for text-only DeepSeek Harness agents: built-in free vision chain + pixel-level tools. Remote vision providers, including the default free fallback, receive image content unless strict local-only use is configured.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,8 +54,8 @@
54
54
  "undici": "^7.29.0"
55
55
  },
56
56
  "peerDependencies": {
57
- "@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.7-rc.2",
58
- "@deepseek-ai/dsh-llm-deepseek": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.7-rc.2",
57
+ "@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.7-rc.2 || >=0.2.0-rc.1 <0.3.0-0",
58
+ "@deepseek-ai/dsh-llm-deepseek": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.7-rc.2 || >=0.2.0-rc.1 <0.3.0-0",
59
59
  "sharp": ">=0.35.3 <1"
60
60
  },
61
61
  "peerDependenciesMeta": {