dsh-vision-router 2.2.0 → 2.2.2

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.
@@ -0,0 +1,416 @@
1
+ const SETTINGS_NS = 'vision-router'
2
+ export const LOCAL_SETTINGS_PATH = '/_dsh/vision-router/local-settings'
3
+ export const DSH_017_SETTINGS_COMPAT_MARK = '__visionRouterDsh017ConfigEditorCompat'
4
+ const BODY_LIMIT_BYTES = 256 * 1024
5
+ const REVISION_REGISTRY_KEY = Symbol.for('dsh-vision-router.settings-017-revisions')
6
+
7
+ function revisionRegistry() {
8
+ let registry = globalThis[REVISION_REGISTRY_KEY]
9
+ if (!(registry instanceof Map)) {
10
+ registry = new Map()
11
+ Object.defineProperty(globalThis, REVISION_REGISTRY_KEY, {
12
+ value: registry,
13
+ configurable: true,
14
+ })
15
+ }
16
+ return registry
17
+ }
18
+
19
+ function objectLike(value) {
20
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
21
+ }
22
+
23
+ function plainObject(value) {
24
+ if (!objectLike(value)) return false
25
+ const proto = Object.getPrototypeOf(value)
26
+ return proto === Object.prototype || proto === null
27
+ }
28
+
29
+ function clone(value) {
30
+ return value === undefined ? undefined : structuredClone(value)
31
+ }
32
+
33
+ function mergeResolved(base, overlay) {
34
+ if (!plainObject(base) || !plainObject(overlay)) return clone(overlay)
35
+ const next = clone(base)
36
+ for (const [key, value] of Object.entries(overlay)) {
37
+ next[key] = plainObject(value) && plainObject(next[key])
38
+ ? mergeResolved(next[key], value)
39
+ : clone(value)
40
+ }
41
+ return next
42
+ }
43
+
44
+ function jsonEqual(left, right) {
45
+ if (left === right) return true
46
+ if (Array.isArray(left) || Array.isArray(right)) {
47
+ return Array.isArray(left) && Array.isArray(right)
48
+ && left.length === right.length
49
+ && left.every((value, index) => jsonEqual(value, right[index]))
50
+ }
51
+ if (!plainObject(left) || !plainObject(right)) return false
52
+ const leftKeys = Object.keys(left)
53
+ const rightKeys = Object.keys(right)
54
+ return leftKeys.length === rightKeys.length
55
+ && leftKeys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key]))
56
+ }
57
+
58
+ function logicalUserLayer(current, inherited) {
59
+ const user = Object.create(null)
60
+ const keys = new Set([
61
+ ...Object.keys(objectLike(current) ? current : {}),
62
+ ...Object.keys(objectLike(inherited) ? inherited : {}),
63
+ ])
64
+ for (const key of keys) {
65
+ const currentHas = objectLike(current) && Object.hasOwn(current, key)
66
+ const inheritedHas = objectLike(inherited) && Object.hasOwn(inherited, key)
67
+ if (currentHas === inheritedHas && (!currentHas || jsonEqual(current[key], inherited[key]))) continue
68
+ if (currentHas) user[key] = clone(current[key])
69
+ }
70
+ return user
71
+ }
72
+
73
+ function stableValue(value) {
74
+ if (Array.isArray(value)) return value.map(stableValue)
75
+ if (!plainObject(value)) return value
76
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]))
77
+ }
78
+
79
+ function fingerprint(value) {
80
+ return JSON.stringify(stableValue(value))
81
+ }
82
+
83
+ function revisionKey(editor, namespace) {
84
+ let documentPath
85
+ try { documentPath = editor?.documentPath } catch {}
86
+ return `${String(documentPath ?? 'process-local-profile')}\u0000${namespace}`
87
+ }
88
+
89
+ /**
90
+ * Ordinary Config writes reload DVR, so a plugin-instance counter is unsafe.
91
+ * Keep one process-level monotonic counter per profile document + namespace.
92
+ * The registry survives DVR HMR and advances whenever the effective persisted
93
+ * layers observed at the compatibility boundary change. A process restart also
94
+ * resets every browser connection, which forces a fresh descriptor read.
95
+ */
96
+ function revisionFor(editor, namespace, current, inherited) {
97
+ const key = revisionKey(editor, namespace)
98
+ const valueFingerprint = fingerprint({ current, inherited })
99
+ const registry = revisionRegistry()
100
+ const previous = registry.get(key)
101
+ if (!previous) {
102
+ const state = { fingerprint: valueFingerprint, revision: 0 }
103
+ registry.set(key, state)
104
+ return state.revision
105
+ }
106
+ if (previous.fingerprint !== valueFingerprint) {
107
+ previous.fingerprint = valueFingerprint
108
+ previous.revision += 1
109
+ }
110
+ return previous.revision
111
+ }
112
+
113
+ function serviceOf(ctx, name) {
114
+ try {
115
+ const value = typeof ctx?.get === 'function' ? ctx.get(name) : undefined
116
+ if (value !== undefined && value !== null) return value
117
+ } catch {}
118
+ try {
119
+ const value = ctx?.[name]
120
+ return value === undefined || value === null ? undefined : value
121
+ } catch {
122
+ return undefined
123
+ }
124
+ }
125
+
126
+ function configEditorRow(editor, namespace) {
127
+ const rows = typeof editor?.configuration === 'function' ? editor.configuration() : []
128
+ if (!Array.isArray(rows)) return undefined
129
+ return rows.find((row) => row?.entry?.options?.id === namespace)
130
+ }
131
+
132
+ function settingsConflict(expected, actual) {
133
+ const error = new Error(`Vision Router settings changed before this write landed (expected ${expected}, actual ${actual})`)
134
+ error.code = 'SETTINGS_CONFLICT'
135
+ error.expected = expected
136
+ error.actual = actual
137
+ return error
138
+ }
139
+
140
+ function admissionError(message) {
141
+ const error = new Error(message)
142
+ error.code = 'SETTINGS_INVALID_MUTATION'
143
+ return error
144
+ }
145
+
146
+ function validateOperations(ops) {
147
+ if (!Array.isArray(ops) || ops.length === 0) throw admissionError('settings operations must be a non-empty array')
148
+ return ops.map((op) => {
149
+ if (!objectLike(op) || (op.op !== 'set' && op.op !== 'unset')) {
150
+ throw admissionError('settings operation must be set or unset')
151
+ }
152
+ if (!Array.isArray(op.path) || op.path.length !== 1 || typeof op.path[0] !== 'string' || op.path[0] === '') {
153
+ throw admissionError('Vision Router settings compatibility accepts one top-level field per operation')
154
+ }
155
+ const key = op.path[0]
156
+ if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
157
+ throw admissionError('unsafe settings field name')
158
+ }
159
+ return op.op === 'set'
160
+ ? { op: 'set', path: [key], value: clone(op.value) }
161
+ : { op: 'unset', path: [key] }
162
+ })
163
+ }
164
+
165
+ function applyOperations(current, inherited, ops) {
166
+ const next = clone(objectLike(current) ? current : {})
167
+ for (const op of ops) {
168
+ const key = op.path[0]
169
+ if (op.op === 'set') {
170
+ next[key] = clone(op.value)
171
+ continue
172
+ }
173
+ if (objectLike(inherited) && Object.hasOwn(inherited, key)) next[key] = clone(inherited[key])
174
+ else delete next[key]
175
+ }
176
+ return next
177
+ }
178
+
179
+ function createConfigEditorSettingsFacade(editor, entryConfig, namespace) {
180
+ const listeners = new Set()
181
+
182
+ function state() {
183
+ const row = configEditorRow(editor, namespace)
184
+ if (!row) return undefined
185
+ const current = objectLike(row.entry?.options?.config) ? row.entry.options.config : {}
186
+ const inherited = objectLike(row.inherited) ? row.inherited : {}
187
+ const base = mergeResolved(entryConfig, inherited)
188
+ const value = mergeResolved(entryConfig, current)
189
+ return {
190
+ row,
191
+ descriptor: {
192
+ ns: namespace,
193
+ value,
194
+ base,
195
+ user: logicalUserLayer(current, inherited),
196
+ revision: revisionFor(editor, namespace, current, inherited),
197
+ applies: true,
198
+ },
199
+ }
200
+ }
201
+
202
+ function notify(value) {
203
+ for (const listener of [...listeners]) {
204
+ try { listener(value, undefined) } catch {}
205
+ }
206
+ }
207
+
208
+ const scope = {
209
+ get() {
210
+ return state()?.descriptor.value ?? entryConfig
211
+ },
212
+ watch(listener) {
213
+ if (typeof listener !== 'function') return () => {}
214
+ listeners.add(listener)
215
+ return () => listeners.delete(listener)
216
+ },
217
+ }
218
+
219
+ return {
220
+ [DSH_017_SETTINGS_COMPAT_MARK]: true,
221
+ get writable() {
222
+ return state() !== undefined
223
+ },
224
+ get(namespaceName) {
225
+ return namespaceName === namespace ? scope.get() : undefined
226
+ },
227
+ register(namespaceName) {
228
+ if (namespaceName !== namespace) {
229
+ throw new Error(`vision-router: unexpected settings namespace ${String(namespaceName)}`)
230
+ }
231
+ return scope
232
+ },
233
+ describe() {
234
+ const current = state()
235
+ return current ? [clone(current.descriptor)] : []
236
+ },
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)
246
+ }
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)
254
+ },
255
+ }
256
+ }
257
+
258
+ /**
259
+ * DSH 0.1.7 services may mount after DVR's apply() starts. Capture ConfigEditor
260
+ * through its own lifecycle injection and resolve the settings generation when
261
+ * each consumer actually accesses it. This avoids both startup-order races and
262
+ * undeclared child-context service reads.
263
+ */
264
+ function contextWithLazySettingsCompatibility(ctx, entryConfig, namespace) {
265
+ const wrappers = new WeakMap()
266
+ const facades = new WeakMap()
267
+ let injectedEditor
268
+
269
+ try {
270
+ if (typeof ctx?.inject === 'function') {
271
+ ctx.inject(['configEditor'], (editorCtx) => {
272
+ injectedEditor = serviceOf(editorCtx, 'configEditor')
273
+ })
274
+ }
275
+ } catch {
276
+ // Older Hosts may not expose ConfigEditor; their native SettingsProvider stays authoritative.
277
+ }
278
+
279
+ function compatibleSettings(target) {
280
+ const settings = serviceOf(target, 'settings') ?? serviceOf(ctx, 'settings')
281
+ if (!settings || typeof settings.register === 'function') return settings
282
+ const editor = serviceOf(target, 'configEditor') ?? injectedEditor ?? serviceOf(ctx, 'configEditor')
283
+ if (typeof settings.describe !== 'function'
284
+ || !editor || typeof editor.edit !== 'function' || typeof editor.configuration !== 'function') {
285
+ return settings
286
+ }
287
+ let facade = facades.get(editor)
288
+ if (!facade) {
289
+ facade = createConfigEditorSettingsFacade(editor, entryConfig, namespace)
290
+ facades.set(editor, facade)
291
+ }
292
+ return facade
293
+ }
294
+
295
+ function wrap(target) {
296
+ if (!target || typeof target !== 'object') return target
297
+ const held = wrappers.get(target)
298
+ if (held) return held
299
+ const wrapped = new Proxy(target, {
300
+ get(object, property) {
301
+ if (property === 'settings') return compatibleSettings(object)
302
+ if (property === 'get') {
303
+ const get = Reflect.get(object, property, object)
304
+ if (typeof get !== 'function') return get
305
+ return (name, ...rest) => name === 'settings'
306
+ ? compatibleSettings(object)
307
+ : get.call(object, name, ...rest)
308
+ }
309
+ if (property === 'inject') {
310
+ const inject = Reflect.get(object, property, object)
311
+ if (typeof inject !== 'function') return inject
312
+ return (dependencies, callback, ...rest) => inject.call(
313
+ object,
314
+ dependencies,
315
+ typeof callback === 'function' && Array.isArray(dependencies) && dependencies.includes('settings')
316
+ ? (child) => callback(wrap(child))
317
+ : callback,
318
+ ...rest,
319
+ )
320
+ }
321
+ const value = Reflect.get(object, property, object)
322
+ return typeof value === 'function' ? value.bind(object) : value
323
+ },
324
+ })
325
+ wrappers.set(target, wrapped)
326
+ return wrapped
327
+ }
328
+
329
+ return wrap(ctx)
330
+ }
331
+
332
+ /**
333
+ * DSH 0.1.7 removes the legacy SettingsProvider.register() namespace API and
334
+ * makes ordinary plugin configuration profile-owned through ConfigEditor.
335
+ * Preserve DVR's mature settings consumers behind the old semantic face while
336
+ * leaving older Hosts on their native SettingsProvider whenever it is present.
337
+ */
338
+ export function installDsh017SettingsCompatibility(ctx, entryConfig = {}, options = {}) {
339
+ const namespace = typeof options.namespace === 'string' && options.namespace !== '' ? options.namespace : SETTINGS_NS
340
+ return contextWithLazySettingsCompatibility(ctx, entryConfig, namespace)
341
+ }
342
+
343
+ function sendJson(res, status, body) {
344
+ res.writeHead(status, {
345
+ 'content-type': 'application/json; charset=utf-8',
346
+ 'cache-control': 'no-store',
347
+ })
348
+ res.end(JSON.stringify(body))
349
+ }
350
+
351
+ async function readJson(req) {
352
+ let size = 0
353
+ const chunks = []
354
+ for await (const chunk of req) {
355
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
356
+ size += bytes.length
357
+ if (size > BODY_LIMIT_BYTES) throw Object.assign(new Error('request body too large'), { statusCode: 413 })
358
+ chunks.push(bytes)
359
+ }
360
+ if (chunks.length === 0) return {}
361
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
362
+ }
363
+
364
+ function namespaceDescriptor(settings) {
365
+ const descriptors = settings.describe({ redactSecrets: true })
366
+ return Array.isArray(descriptors) ? descriptors.find((entry) => entry?.ns === SETTINGS_NS) : undefined
367
+ }
368
+
369
+ /** Local-only transport for the 0.1.7 browser, which no longer exposes settingsScope. */
370
+ export function installDsh017LocalSettingsTransport(ctx) {
371
+ if (!ctx || typeof ctx.inject !== 'function') return
372
+ ctx.inject(['settings', 'webServer'], (webCtx) => {
373
+ if (webCtx.settings?.[DSH_017_SETTINGS_COMPAT_MARK] !== true) return
374
+ if (typeof webCtx.settings.describe !== 'function' || typeof webCtx.settings.mutate !== 'function') return
375
+ webCtx.effect(
376
+ () => webCtx.webServer.register({
377
+ kind: 'exact',
378
+ path: LOCAL_SETTINGS_PATH,
379
+ async handler(req, res) {
380
+ if (req.method === 'GET') {
381
+ const descriptor = namespaceDescriptor(webCtx.settings)
382
+ if (!descriptor) {
383
+ sendJson(res, 404, { ok: false, error: { code: 'settings-unavailable', message: 'Vision Router settings are unavailable' } })
384
+ return
385
+ }
386
+ sendJson(res, 200, { ok: true, value: { ...descriptor, writable: webCtx.settings.writable === true } })
387
+ return
388
+ }
389
+ if (req.method !== 'POST') {
390
+ res.setHeader('Allow', 'GET, POST')
391
+ sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
392
+ return
393
+ }
394
+ try {
395
+ const payload = await readJson(req)
396
+ await webCtx.settings.mutate(SETTINGS_NS, payload?.ops, payload?.expectedRevision)
397
+ const descriptor = namespaceDescriptor(webCtx.settings)
398
+ if (!descriptor) throw new Error('Vision Router settings disappeared after the write')
399
+ sendJson(res, 200, { ok: true, value: { ...descriptor, writable: webCtx.settings.writable === true } })
400
+ } catch (error) {
401
+ const conflict = error?.code === 'SETTINGS_CONFLICT'
402
+ sendJson(res, error?.statusCode ?? (conflict ? 409 : 400), {
403
+ ok: false,
404
+ error: {
405
+ code: conflict ? 'settings-conflict' : 'settings-rejected',
406
+ message: error?.message ?? String(error),
407
+ ...(conflict ? { details: { expected: error.expected, actual: error.actual } } : {}),
408
+ },
409
+ })
410
+ }
411
+ },
412
+ }),
413
+ 'vision-router: DSH 0.1.7 local settings transport',
414
+ )
415
+ })
416
+ }
@@ -0,0 +1,56 @@
1
+ // Lifecycle metadata only: no native fetch snapshot, dispatcher, or egress
2
+ // policy is retained here. Weak keys let abandoned wrapper chains be collected.
3
+ const installations = new WeakMap()
4
+
5
+ function livePreviousDescriptor(descriptor) {
6
+ let previous = descriptor
7
+ while (previous && Object.hasOwn(previous, 'value')) {
8
+ const installation = installations.get(previous.value)
9
+ if (!installation?.disposed) break
10
+ previous = installation.previous
11
+ }
12
+ return previous
13
+ }
14
+
15
+ /** Preserve a live accessor-owned pipeline, including middleware recomposition. */
16
+ export function captureFetchDelegate(target = globalThis) {
17
+ const descriptor = Object.getOwnPropertyDescriptor(target, 'fetch')
18
+ const current = target.fetch
19
+ if (typeof current !== 'function' || typeof descriptor?.get !== 'function') return current
20
+ return (input, init) => Reflect.apply(Reflect.apply(descriptor.get, target, []), target, [input, init])
21
+ }
22
+
23
+ /**
24
+ * Publish an already constructed fetch wrapper without calling a foreign
25
+ * accessor setter. Assignment is unsafe: a pipeline getter may return a
26
+ * function whose setter adopts our wrapper as its own delegate (#519).
27
+ *
28
+ * The caller captures the CURRENT Host chain at installation, never at module
29
+ * load, and disables its own behavior before restoring. Later Host replacements
30
+ * remain authoritative. Restore the exact previous descriptor only while our
31
+ * data property still owns the surface; never feed a wrapper back to a setter.
32
+ * Non-configurable accessors cannot be composed this way and fail before any
33
+ * mutation rather than silently bypassing a Host policy or explicit proxy.
34
+ */
35
+ export function installFetchWrapper(wrapped, target = globalThis) {
36
+ if (typeof wrapped !== 'function') throw new TypeError('fetch wrapper must be a function')
37
+ const previous = Object.getOwnPropertyDescriptor(target, 'fetch')
38
+ if (previous && Object.hasOwn(previous, 'value') && previous.value === wrapped) return () => {}
39
+ const installed = previous && Object.hasOwn(previous, 'value')
40
+ ? { ...previous, value: wrapped }
41
+ : { configurable: true, enumerable: previous?.enumerable ?? true, writable: true, value: wrapped }
42
+ Object.defineProperty(target, 'fetch', installed)
43
+ const installation = { previous, disposed: false }
44
+ installations.set(wrapped, installation)
45
+ return () => {
46
+ if (installation.disposed) return
47
+ installation.disposed = true
48
+ const current = Object.getOwnPropertyDescriptor(target, 'fetch')
49
+ if (!current || !Object.hasOwn(current, 'value') || current.value !== wrapped ||
50
+ current.configurable !== installed.configurable ||
51
+ current.enumerable !== installed.enumerable || current.writable !== installed.writable) return
52
+ const restore = livePreviousDescriptor(previous)
53
+ if (restore) Object.defineProperty(target, 'fetch', restore)
54
+ else delete target.fetch
55
+ }
56
+ }
@@ -1,3 +1,4 @@
1
+ import { captureFetchDelegate, installFetchWrapper } from './fetch-wrapper-lifecycle.js'
1
2
  // H2: retain the legacy Host-owned proxy override as a scoped compatibility
2
3
  // transport, not as process-wide routing authority. The wrapper is always
3
4
  // transparent unless one DVR-owned visual adapter call is active (or the
@@ -184,7 +185,7 @@ function proxyHostsOf(config) {
184
185
  * the narrow legacy direct whole-turn fallback above.
185
186
  */
186
187
  export function installLegacyGlobalProxyBoundary(ctx, config = {}, options = {}) {
187
- const originalFetch = typeof options.originalFetch === 'function' ? options.originalFetch : globalThis.fetch
188
+ const originalFetch = typeof options.originalFetch === 'function' ? options.originalFetch : captureFetchDelegate()
188
189
  const importUndici = typeof options.importUndici === 'function' ? options.importUndici : () => import('undici')
189
190
  if (typeof originalFetch !== 'function') return () => {}
190
191
 
@@ -227,14 +228,14 @@ export function installLegacyGlobalProxyBoundary(ctx, config = {}, options = {})
227
228
  })
228
229
  }
229
230
 
230
- globalThis.fetch = scopedFetch
231
+ const restoreFetch = installFetchWrapper(scopedFetch)
231
232
  let disposed = false
232
233
  const dispose = () => {
233
234
  if (disposed) return
234
235
  disposed = true
235
236
  active = false
236
237
  void dispatcherPool.dispose()
237
- if (globalThis.fetch === scopedFetch) globalThis.fetch = originalFetch
238
+ restoreFetch()
238
239
  }
239
240
  if (typeof ctx?.effect === 'function') {
240
241
  try {
@@ -1,3 +1,4 @@
1
+ import { captureFetchDelegate, installFetchWrapper } from './fetch-wrapper-lifecycle.js'
1
2
  import { currentVisionSessionAffinityId } from './session-affinity-runtime.js'
2
3
  import { isOfficialOpenCodeGoUrl, openCodeSessionAffinityHeaderForUrl } from './session-affinity.js'
3
4
 
@@ -283,7 +284,7 @@ export function applyPiAiBridgeWireFacts(init, body, facts) {
283
284
  * wrapper even if another later patch sits above it in the fetch chain.
284
285
  */
285
286
  export function installPiAiBridgeWireCompat(ctx, logger) {
286
- const original = globalThis.fetch
287
+ const original = captureFetchDelegate()
287
288
  if (typeof original !== 'function') return () => {}
288
289
  let active = true
289
290
  const wrapped = async (input, init) => {
@@ -334,10 +335,10 @@ export function installPiAiBridgeWireCompat(ctx, logger) {
334
335
  }
335
336
  return Reflect.apply(original, globalThis, [input, patchedInit])
336
337
  }
337
- globalThis.fetch = wrapped
338
+ const restoreFetch = installFetchWrapper(wrapped)
338
339
  const cleanup = () => {
339
340
  active = false
340
- if (globalThis.fetch === wrapped) globalThis.fetch = original
341
+ restoreFetch()
341
342
  }
342
343
  try {
343
344
  ctx?.effect?.(() => cleanup, 'vision-router: pi-ai bridge wire compatibility')
@@ -37,12 +37,18 @@ class AsyncByteReader {
37
37
  function cellBounds(index, width, height, cols, rows) {
38
38
  const cx = index % cols
39
39
  const cy = Math.floor(index / cols)
40
- const cw = Math.ceil(width / cols)
41
- const ch = Math.ceil(height / rows)
42
- const x1 = cx * cw
43
- const y1 = cy * ch
44
- const x2 = Math.min((cx + 1) * cw, width)
45
- const y2 = Math.min((cy + 1) * ch, height)
40
+ // Uniform boundaries mirror the counting map floor(x * cells / extent)
41
+ // exactly: with cells <= extent (the grid clamp above guarantees it),
42
+ // ceil(c * extent / cells) rises by at least 1 per cell, so every cell is
43
+ // non-empty and every pixel lands inside the cell the counters credited
44
+ // it to. The previous ceil-stride bounds drifted from that map whenever
45
+ // extent was not a multiple of cells, shifting worst-region boxes and, on
46
+ // tiny extents, dropping counted pixels from cells whose bounds collapsed
47
+ // to zero width.
48
+ const x1 = Math.ceil((cx * width) / cols)
49
+ const y1 = Math.ceil((cy * height) / rows)
50
+ const x2 = Math.ceil(((cx + 1) * width) / cols)
51
+ const y2 = Math.ceil(((cy + 1) * height) / rows)
46
52
  return { x1, y1, x2, y2, total: Math.max(0, x2 - x1) * Math.max(0, y2 - y1) }
47
53
  }
48
54
 
@@ -7,6 +7,10 @@ import {
7
7
  installVisionProviderTransport,
8
8
  } from './vision-provider-transport.js'
9
9
  import { installLegacyGlobalProxyBoundary } from './legacy-global-proxy-boundary.js'
10
+ import { installDsh017SettingsCompatibility } from './dsh-settings-017-compat.js'
11
+ import { installDsh017RootLocalSettingsTransport } from './web/dsh-settings-017-root-transport.js'
12
+ import { installSettings017ClientCompatibility } from './settings-client-017-compat.js'
13
+ import { installVisionRouterMessageSourceBoundary } from './session-message-source-compat.js'
10
14
 
11
15
  export * from '../entry.js'
12
16
 
@@ -33,7 +37,23 @@ function liveVisionConfig(ctx, fallback) {
33
37
  * settings therefore leave DSH/Host as the sole network authority.
34
38
  */
35
39
  export function apply(ctx, config = {}) {
36
- const hardening = createVisionToggleRootHardening(ctx, config)
40
+ // DSH 0.1.7 replaces SettingsProvider.register() with profile-owned
41
+ // ConfigEditor writes for ordinary fields. Feature-detect that exact contract
42
+ // and present DVR's mature settings face without changing older Hosts.
43
+ const settingsCtx = installDsh017SettingsCompatibility(ctx, config, {
44
+ namespace: 'vision-router',
45
+ Config: base.Config,
46
+ })
47
+ // The 0.1.7 browser no longer exposes settingsScope. Keep the server route on
48
+ // the application root so ConfigEditor reconciliation cannot dispose an
49
+ // in-flight Settings POST, while each DVR generation supplies the live facade.
50
+ installDsh017RootLocalSettingsTransport(settingsCtx)
51
+ installSettings017ClientCompatibility(settingsCtx)
52
+ // Session V3 and V4 disagree on plugin message attribution. Normalize only
53
+ // DVR-authored contexts at the final hook-publication seam using the Session's
54
+ // own durable header version, never a DSH package-version guess.
55
+ const sessionSourceCtx = installVisionRouterMessageSourceBoundary(settingsCtx)
56
+ const hardening = createVisionToggleRootHardening(sessionSourceCtx, config)
37
57
  const runtimeCtx = contextWithVisionRoutingTopologyRefresh(hardening.ctx)
38
58
  const transport = createVisionProviderTransport({
39
59
  ctx: hardening.ctx,
@@ -0,0 +1,92 @@
1
+ const LEGACY_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'dsh-vision-router' })
2
+ const PRODUCER_SOURCE = Object.freeze({ kind: 'plugin:dsh-vision-router' })
3
+
4
+ function sourceKindForSession(session) {
5
+ const version = Number(session?.header?.version)
6
+ return Number.isInteger(version) && version >= 4 ? PRODUCER_SOURCE : LEGACY_SOURCE
7
+ }
8
+
9
+ function isVisionRouterSource(source) {
10
+ return !!source && typeof source === 'object' && !Array.isArray(source) && (
11
+ (source.kind === 'plugin' && source.plugin === 'dsh-vision-router') ||
12
+ source.kind === 'plugin:dsh-vision-router'
13
+ )
14
+ }
15
+
16
+ /** Return the canonical DVR message source for one live Session format. */
17
+ export function visionRouterMessageSource(session) {
18
+ return { ...sourceKindForSession(session) }
19
+ }
20
+
21
+ /** Normalize one DVR-authored message without touching user/model/tool sources. */
22
+ export function normalizeVisionRouterMessageSource(message, session) {
23
+ if (!message || typeof message !== 'object' || Array.isArray(message)) return message
24
+ if (!isVisionRouterSource(message.source)) return message
25
+ const source = sourceKindForSession(session)
26
+ if (message.source.kind === source.kind
27
+ && (source.kind !== 'plugin' || message.source.plugin === source.plugin)
28
+ && Object.keys(message.source).length === Object.keys(source).length) {
29
+ return message
30
+ }
31
+ return { ...message, source: { ...source } }
32
+ }
33
+
34
+ function normalizeMessageList(list, session) {
35
+ if (!Array.isArray(list)) return list
36
+ let changed = false
37
+ const next = list.map((message) => {
38
+ const normalized = normalizeVisionRouterMessageSource(message, session)
39
+ if (normalized !== message) changed = true
40
+ return normalized
41
+ })
42
+ return changed ? next : list
43
+ }
44
+
45
+ function normalizePreStepResult(result, payload) {
46
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return result
47
+ const messages = normalizeMessageList(result.messages, payload?.agent?.session)
48
+ return messages === result.messages ? result : { ...result, messages }
49
+ }
50
+
51
+ function normalizePostExecuteResult(result, exec) {
52
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return result
53
+ const additionalContexts = normalizeMessageList(result.additionalContexts, exec?.agent?.session)
54
+ return additionalContexts === result.additionalContexts ? result : { ...result, additionalContexts }
55
+ }
56
+
57
+ function normalizeHookResult(event, args, result) {
58
+ if (event === 'agent/pre-step') return normalizePreStepResult(result, args[0])
59
+ if (event === 'tools/post-execute') return normalizePostExecuteResult(result, args[0])
60
+ return result
61
+ }
62
+
63
+ /**
64
+ * DSH Session V3 requires the historical {kind:'plugin', plugin:...} source,
65
+ * while V4 rejects that wrapper in favor of producer-owned source kinds. Keep
66
+ * every existing DVR message producer unchanged and normalize at the final
67
+ * hook publication boundary using the Session's own durable header version.
68
+ */
69
+ export function installVisionRouterMessageSourceBoundary(ctx) {
70
+ if (!ctx || typeof ctx !== 'object') return ctx
71
+ return new Proxy(ctx, {
72
+ get(target, property) {
73
+ if (property !== 'on') {
74
+ const value = Reflect.get(target, property, target)
75
+ return typeof value === 'function' ? value.bind(target) : value
76
+ }
77
+ const on = Reflect.get(target, property, target)
78
+ if (typeof on !== 'function') return on
79
+ return (event, handler, ...rest) => {
80
+ if ((event !== 'agent/pre-step' && event !== 'tools/post-execute') || typeof handler !== 'function') {
81
+ return on.call(target, event, handler, ...rest)
82
+ }
83
+ return on.call(target, event, function sourceCompatibleHook(...args) {
84
+ const output = handler.apply(this, args)
85
+ return output && typeof output.then === 'function'
86
+ ? output.then((value) => normalizeHookResult(event, args, value))
87
+ : normalizeHookResult(event, args, output)
88
+ }, ...rest)
89
+ }
90
+ },
91
+ })
92
+ }