dsh-vision-router 2.2.0 → 2.2.1

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
@@ -17,7 +17,7 @@
17
17
  </p>
18
18
 
19
19
  <p align="center">
20
- <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v2.2.0"><img src="https://img.shields.io/badge/release-v2.2.0-5B4CF0?style=flat-square" alt="Release v2.2.0" /></a>
20
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v2.2.1"><img src="https://img.shields.io/badge/release-v2.2.1-5B4CF0?style=flat-square" alt="Release v2.2.1" /></a>
21
21
  <a href="tests"><img src="https://img.shields.io/badge/verified-Node%2022%20%2B%2024-2EA44F?style=flat-square" alt="Verified: Node 22 + 24" /></a>
22
22
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat-square" alt="License: MIT" /></a>
23
23
  <a href="package.json"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white" alt="Node.js >=22" /></a>
@@ -39,9 +39,9 @@
39
39
  <p align="center">💬 <strong>QQ community group: 1105463028</strong></p>
40
40
 
41
41
  > [!WARNING]
42
- > 📌 **Announcement (v2.2.0)**
42
+ > 📌 **Announcement (v2.2.1)**
43
43
  >
44
- > **v2.2.0:** Lands Vision Quality Round 2 with adaptive local OCR, bounded degraded-evidence work, stable artifact handoff, successful-evidence convergence, and stronger DSH/session compatibility — without raising the rc.8 Host floor. [What’s new →](docs/releases/v2.2.0.md)
44
+ > **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)
45
45
 
46
46
  <p align="center">
47
47
  <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" />
package/README.zh.md CHANGED
@@ -17,7 +17,7 @@
17
17
  </p>
18
18
 
19
19
  <p align="center">
20
- <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v2.2.0"><img src="https://img.shields.io/badge/release-v2.2.0-5B4CF0?style=flat-square" alt="Release v2.2.0" /></a>
20
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v2.2.1"><img src="https://img.shields.io/badge/release-v2.2.1-5B4CF0?style=flat-square" alt="Release v2.2.1" /></a>
21
21
  <a href="tests"><img src="https://img.shields.io/badge/verified-Node%2022%20%2B%2024-2EA44F?style=flat-square" alt="已验证 Node 22 + 24" /></a>
22
22
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat-square" alt="MIT 许可证" /></a>
23
23
  <a href="package.json"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white" alt="Node.js >=22" /></a>
@@ -39,9 +39,9 @@
39
39
  <p align="center">💬 <strong>QQ 用户交流群:1105463028</strong></p>
40
40
 
41
41
  > [!WARNING]
42
- > 📌 **公告(v2.2.0)**
42
+ > 📌 **公告(v2.2.1)**
43
43
  >
44
- > **v2.2.0:Vision Quality Round 2 正式落地——自适应本地OCR、失败证据有界收敛、稳定artifact handoff、成功证据停止重复调用,并进一步收敛DSH/Session兼容;公开最低Host仍保持 `0.1.0-rc.8`。** [查看完整更新 →](docs/releases/v2.2.0.md)
44
+ > **v2.2.1:稳定性热修——修复大型 Session repair 卡死、accessor fetch 组合、非整除 pixel diff 边界与本地 Ollama 推理占满答案预算;公开最低 Host 仍保持 `0.1.0-rc.8`。** [查看完整更新 →](docs/releases/v2.2.1.md)
45
45
 
46
46
  <p align="center">
47
47
  <img src="assets/vision-demo.gif" width="640" alt="演示:粘贴图片,Agent 用 vision_ground / vision_crop / vision_pixel_diff 定位发送按钮并给出坐标" />
@@ -0,0 +1,22 @@
1
+ # v2.2.1
2
+
3
+ DSH Vision Router 2.2.1 is a focused stability and compatibility hotfix for the 2.2.0 line.
4
+
5
+ ## Highlights
6
+
7
+ - Fixes the large-session repair regressions in #524 and #532. On supported DSH Hosts, production tool-result and guard-stop repair now consumes bounded post-commit `session/event` entries and performs zero live Session-history reads: no `sessionQuery.readEvent()`, no `observeSession.events` materialization, and no new `eventAt()` / `snapshotEvents()` dependency. This removes the original full-clone amplification and the later `Unable to deserialize cloned data` / Windows Node 24 native-crash path.
8
+ - Fixes global `fetch` compatibility composition with accessor-based Host/plugin pipelines (#519), preventing wrapper cycles while preserving live Host middleware authority.
9
+ - Fixes `vision_pixel_diff` worst-region bounds on non-divisible image/grid dimensions (#520).
10
+ - Fixes the confirmed Ollama portion of #526 by disabling hidden reasoning for generated local Ollama vision providers on the OpenAI-compatible Chat Completions path, preserving the bounded token budget for visible answer text.
11
+ - Completes the release-channel canary fixture so stable/latest and preview/alpha validate the same Session publication family before Host contract smoke.
12
+
13
+ ## Compatibility
14
+
15
+ - Node 22 and Node 24 remain supported.
16
+ - Windows, macOS, and Linux remain gated.
17
+ - The public minimum DSH Host remains `0.1.0-rc.8`.
18
+ - Existing 2.2.0 settings require no migration.
19
+
20
+ ## Known follow-ups
21
+
22
+ - #526 remains open for the LM Studio reasoning/budget path; 2.2.1 does not send Ollama-specific `reasoning_effort` to LM Studio without a documented LM Studio wire contract.
@@ -1858,6 +1858,10 @@ export function localOllamaProvidersOf(config) {
1858
1858
  model,
1859
1859
  apiKeyEnv: '',
1860
1860
  maxTokens: 2048,
1861
+ // Ollama's OpenAI-compatible Chat Completions endpoint supports
1862
+ // reasoning_effort='none'. Vision extraction should spend the bounded
1863
+ // completion budget on observable answer text rather than hidden thought.
1864
+ reasoningEffort: 'none',
1861
1865
  // 仅显式选择 anthropic 格式时携带(默认 openai 路径保持字节不变)。
1862
1866
  ...(local.format === 'anthropic' ? { format: 'anthropic' } : {}),
1863
1867
  // 建议值透传:温度/top_p 只在显式配置时携带(callOpenAICompatible
@@ -1978,6 +1982,9 @@ export async function callLocalBackend(provider, messages, options = {}) {
1978
1982
  ...(typeof options.resolveCredential === 'function'
1979
1983
  ? { resolveCredential: options.resolveCredential }
1980
1984
  : {}),
1985
+ ...(typeof provider.reasoningEffort === 'string'
1986
+ ? { reasoningEffort: provider.reasoningEffort }
1987
+ : {}),
1981
1988
  ...sampling,
1982
1989
  })
1983
1990
  }
@@ -2121,6 +2128,7 @@ export async function callOpenAICompatible(provider, messages, options = {}) {
2121
2128
  if (resolvedApiKey === '') throw new Error(`http provider "${provider.name}": ${apiKeyEnv} is not set`)
2122
2129
  headers.authorization = `Bearer ${resolvedApiKey}`
2123
2130
  }
2131
+ const reasoningEffort = options.reasoningEffort ?? provider.reasoningEffort
2124
2132
  const body = {
2125
2133
  model: provider.model,
2126
2134
  messages,
@@ -2130,6 +2138,9 @@ export async function callOpenAICompatible(provider, messages, options = {}) {
2130
2138
  // never pass them, so the wire body stays byte-identical for main paths.
2131
2139
  ...(typeof options.temperature === 'number' ? { temperature: options.temperature } : {}),
2132
2140
  ...(typeof options.top_p === 'number' ? { top_p: options.top_p } : {}),
2141
+ ...(['none', 'low', 'medium', 'high', 'max'].includes(reasoningEffort)
2142
+ ? { reasoning_effort: reasoningEffort }
2143
+ : {}),
2133
2144
  }
2134
2145
  const url = `${provider.baseURL.replace(/\/$/, '')}/chat/completions`
2135
2146
  const request = () =>
@@ -192,17 +192,40 @@ export function createSessionEventTailReader(ctx) {
192
192
 
193
193
 
194
194
  /**
195
- * Build the narrow asynchronous Session-log reader used only for cold
196
- * canonical attachment recovery after the bounded cache evicts a ref.
197
- * Supported Hosts own replay validation through `sessionQuery.readSession()`;
198
- * callers receive detached events and never touch synchronous Session history.
195
+ * Build the asynchronous Session-log reader used by cold attachment recovery
196
+ * and batched surface repair. Prefer SessionQuery's observation lease when it
197
+ * exists: one exact live cut materializes at most one immutable event snapshot,
198
+ * rather than cloning the whole live corpus once per requested event (#524).
199
+ * Hosts without that capability retain the released readSession() contract.
199
200
  */
200
201
  export function createSessionLogReader(ctx) {
201
202
  return async function readSessionLog(session) {
202
203
  const sessionId = session?.id
203
204
  if (sessionId === undefined || sessionId === null) return { supported: false }
204
205
  const query = sessionQueryOf(ctx)
205
- if (!query || typeof query.readSession !== 'function') return { supported: false }
206
+ if (!query) return { supported: false }
207
+
208
+ if (typeof query.observeSession === 'function') {
209
+ let observation
210
+ try {
211
+ observation = await query.observeSession(sessionId, { projectionMode: 'none' })
212
+ if (!observation || !Array.isArray(observation.events)) {
213
+ throw new Error('sessionQuery.observeSession returned no event log')
214
+ }
215
+ const observedId = observation.header?.id
216
+ if (observedId !== undefined && observedId !== sessionId) {
217
+ throw new Error(
218
+ `sessionQuery.observeSession returned session ${String(observedId)} for requested ${String(sessionId)}`,
219
+ )
220
+ }
221
+ return { supported: true, events: observation.events }
222
+ } finally {
223
+ const dispose = observation?.[Symbol.dispose]
224
+ if (typeof dispose === 'function') dispose.call(observation)
225
+ }
226
+ }
227
+
228
+ if (typeof query.readSession !== 'function') return { supported: false }
206
229
  const snapshot = await query.readSession(sessionId)
207
230
  if (!snapshot || !Array.isArray(snapshot.events)) {
208
231
  throw new Error('sessionQuery.readSession returned no event log')
@@ -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
 
@@ -2,6 +2,8 @@ import { currentSessionSurfacePolicy } from './session-surface-policy.js'
2
2
  import { sessionSurfaceReplacementIntent } from './session-surface-compat.js'
3
3
  import { createSessionVisionStateStore } from './session-vision-state.js'
4
4
 
5
+ const MAX_PENDING_REPAIR_EVENTS = 256
6
+
5
7
  function isObject(value) {
6
8
  return value !== null && typeof value === 'object'
7
9
  }
@@ -39,13 +41,23 @@ function boundedMessage(error) {
39
41
  return String(text ?? '').slice(0, 400)
40
42
  }
41
43
 
44
+ function isReplacementEvent(event) {
45
+ const op = event?.surfaceOp
46
+ return !!op && typeof op === 'object' && !Array.isArray(op) && op.op === 'replace'
47
+ }
48
+
42
49
  /**
43
50
  * Session visual data-plane index.
44
51
  *
45
52
  * SessionVisionStateStore remains the bounded storage owner. This object owns
46
- * target-only durable attachment recovery after bounded-cache eviction,
47
- * bounded Host-backed tool-result image surface repair and expired structured
48
- * guard-stop surface repair.
53
+ * target-only durable attachment recovery, event-feed-backed tool-result image
54
+ * surface repair and expired structured guard-stop surface repair.
55
+ *
56
+ * Supported DSH Hosts expose the post-commit `session/event` feed. Production
57
+ * surface repair consumes only the exact frozen events observed on that feed;
58
+ * it never asks SessionQuery to materialize or clone the live Session history.
59
+ * The older reader path remains only for isolated/partial callers that do not
60
+ * install the event feed boundary.
49
61
  *
50
62
  * The index never replaces stateStore.lookupAttachment(). Cache lookup stays
51
63
  * synchronous through lookupAttachment(); cold durable recovery is requested
@@ -73,16 +85,20 @@ export function createSessionVisionIndex({
73
85
  : undefined
74
86
  const toolSurfaceScans = new WeakMap()
75
87
  const guardSurfaceScans = new WeakMap()
88
+ const pendingToolRepairEvents = new WeakMap()
89
+ const pendingGuardRepairEvents = new WeakMap()
76
90
  const unsupportedSurfaceContracts = new WeakSet()
77
91
  const repairReadWarnings = new WeakMap()
92
+ const repairFeedOverflowWarnings = new WeakSet()
93
+ const repairFeedObserverWarnings = new WeakMap()
78
94
  const attachmentRecoveryWarnings = new WeakMap()
95
+ let surfaceEventFeedActive = false
79
96
 
80
97
  const recordAttachments = (session, refs) => {
81
98
  if (!session || !Array.isArray(refs) || refs.length === 0) return
82
99
  store.recordAttachments(session, refs)
83
100
  }
84
101
 
85
-
86
102
  // Synchronous lookup is cache-only. Projected short handles first warm this
87
103
  // cache from current derived messages and must not trigger historical I/O.
88
104
  const lookupAttachment = (session, id) => {
@@ -217,13 +233,47 @@ export function createSessionVisionIndex({
217
233
  }
218
234
 
219
235
  // Isolated/partial Hosts that do not expose SessionQuery retain the released
220
- // Session-local compatibility path. Supported production Hosts use the
221
- // bounded async reader above and never silently fall back after a read error.
236
+ // Session-local compatibility path. Supported production Hosts install the
237
+ // event feed and never enter this compatibility reader.
222
238
  const events = legacySessionEvents(session)
223
239
  if (!events) return { readable: false }
224
240
  return { readable: true, event: events[seq] }
225
241
  }
226
242
 
243
+ const surfaceRepairSnapshot = async (session, seq) => {
244
+ if (typeof readSessionLog !== 'function') return undefined
245
+ let result
246
+ try {
247
+ result = await readSessionLog(session)
248
+ } catch (error) {
249
+ warnRepairReadFailure(session, seq, error)
250
+ return { readable: false }
251
+ }
252
+ if (result?.supported === true) {
253
+ if (!Array.isArray(result.events)) {
254
+ warnRepairReadFailure(session, seq, new Error('Session log reader returned no event log'))
255
+ return { readable: false }
256
+ }
257
+ repairReadWarnings.delete(session)
258
+ return { readable: true, events: result.events }
259
+ }
260
+ if (result?.supported !== false) {
261
+ warnRepairReadFailure(session, seq, new Error('Session log reader returned an invalid capability result'))
262
+ return { readable: false }
263
+ }
264
+ return undefined
265
+ }
266
+
267
+ const eventFromRepairSnapshot = (session, events, seq) => {
268
+ const event = events?.[seq]
269
+ if (!event || typeof event !== 'object' || (event.seq !== undefined && event.seq !== seq)) {
270
+ warnRepairReadFailure(session, seq, new Error(`Session repair snapshot has no event at seq ${seq}`))
271
+ return { readable: false }
272
+ }
273
+ repairReadWarnings.delete(session)
274
+ return { readable: true, event }
275
+ }
276
+
227
277
  const keepToolResultImages = () => {
228
278
  const policy = currentSessionSurfacePolicy(
229
279
  typeof config === 'function' ? config() : config,
@@ -244,14 +294,165 @@ export function createSessionVisionIndex({
244
294
  return undefined
245
295
  }
246
296
 
297
+ const cachePendingRepairEvent = (pendingBySession, session, event) => {
298
+ if (!session || !Number.isSafeInteger(event?.seq) || event.seq < 0 || Object.is(event.seq, -0)) return
299
+ let pending = pendingBySession.get(session)
300
+ if (!pending) {
301
+ pending = new Map()
302
+ pendingBySession.set(session, pending)
303
+ }
304
+ if (!pending.has(event.seq) && pending.size >= MAX_PENDING_REPAIR_EVENTS) {
305
+ const oldest = pending.keys().next().value
306
+ if (oldest !== undefined) pending.delete(oldest)
307
+ if (!repairFeedOverflowWarnings.has(session)) {
308
+ repairFeedOverflowWarnings.add(session)
309
+ logger?.warn?.(
310
+ 'vision-router: session surface repair event feed exceeded %s pending events; dropping oldest entry',
311
+ MAX_PENDING_REPAIR_EVENTS,
312
+ )
313
+ }
314
+ }
315
+ pending.set(event.seq, event)
316
+ }
317
+
318
+ const planAgainstOneEvent = (event, planner) => {
319
+ if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || Object.is(event.seq, -0)) return []
320
+ const events = { [event.seq]: event }
321
+ return planner(events, [event.seq])
322
+ }
323
+
324
+ const recordSessionEvent = (session, event) => {
325
+ if (!session || !event || typeof event !== 'object') return
326
+ try {
327
+ if (typeof core.collectEventAttachmentRefs === 'function') {
328
+ const refs = core.collectEventAttachmentRefs([event])
329
+ if (Array.isArray(refs) && refs.length > 0) recordAttachments(session, refs)
330
+ }
331
+
332
+ // Replacement events are consequences of this repair path and must never
333
+ // be re-enqueued, otherwise a guard-stop replacement would shadow itself
334
+ // forever because its stable plugin-owned id is intentionally preserved.
335
+ if (isReplacementEvent(event)) return
336
+
337
+ if (event.type === 'tool/result' && typeof core.planToolResultImageShadows === 'function') {
338
+ const plans = planAgainstOneEvent(
339
+ event,
340
+ (events, seqs) => core.planToolResultImageShadows(events, seqs, () => true),
341
+ )
342
+ if (plans.length > 0) cachePendingRepairEvent(pendingToolRepairEvents, session, event)
343
+ } else if (event.type === 'user/message' && typeof core.planGuardStopShadows === 'function') {
344
+ const plans = planAgainstOneEvent(event, (events, seqs) => core.planGuardStopShadows(events, seqs))
345
+ if (plans.length > 0) cachePendingRepairEvent(pendingGuardRepairEvents, session, event)
346
+ }
347
+ repairFeedObserverWarnings.delete(session)
348
+ } catch (error) {
349
+ const message = boundedMessage(error)
350
+ if (repairFeedObserverWarnings.get(session) === message) return
351
+ repairFeedObserverWarnings.set(session, message)
352
+ logger?.warn?.('vision-router: session event feed observation failed error=%s', message)
353
+ }
354
+ }
355
+
356
+ const activateSurfaceEventFeed = () => {
357
+ surfaceEventFeedActive = true
358
+ }
359
+
360
+ const consumePendingRepairEvents = (pendingBySession, session) => {
361
+ const pending = pendingBySession.get(session)
362
+ if (!pending || pending.size === 0) return []
363
+ const nodes = surfaceNodes(session)
364
+ if (!nodes || nodes.length === 0) {
365
+ pending.clear()
366
+ return []
367
+ }
368
+ const current = new Set(nodes)
369
+ for (const seq of pending.keys()) {
370
+ if (!current.has(seq)) pending.delete(seq)
371
+ }
372
+ const entries = []
373
+ for (const seq of nodes) {
374
+ const event = pending.get(seq)
375
+ if (event === undefined) continue
376
+ pending.delete(seq)
377
+ entries.push({ seq, event })
378
+ }
379
+ return entries
380
+ }
381
+
382
+ const repairToolResultSurfaceFromFeed = async (session) => {
383
+ if (typeof session?.append !== 'function' || typeof core.planToolResultImageShadows !== 'function') return 0
384
+ const pending = consumePendingRepairEvents(pendingToolRepairEvents, session)
385
+ if (pending.length === 0) return 0
386
+ const preserve = keepToolResultImages()
387
+ let repaired = 0
388
+ for (const { seq, event } of pending) {
389
+ const events = { [seq]: event }
390
+ const plans = core.planToolResultImageShadows(events, [seq], () => !preserve)
391
+ for (const plan of plans) {
392
+ try {
393
+ const intent = replacementIntent(session, plan.seq)
394
+ if (intent === undefined) continue
395
+ await session.append(
396
+ 'tool/result',
397
+ { ...plan.event.data, message: plan.message },
398
+ intent,
399
+ )
400
+ repaired += 1
401
+ } catch (error) {
402
+ logger?.warn?.(
403
+ 'vision-router: session tool-result surface repair failed seq=%s error=%s',
404
+ plan.seq,
405
+ boundedMessage(error),
406
+ )
407
+ }
408
+ }
409
+ }
410
+ return repaired
411
+ }
412
+
413
+ const repairGuardStopSurfaceFromFeed = async (session) => {
414
+ if (typeof session?.append !== 'function' || typeof core.planGuardStopShadows !== 'function') return 0
415
+ const pending = consumePendingRepairEvents(pendingGuardRepairEvents, session)
416
+ if (pending.length === 0) return 0
417
+ let repaired = 0
418
+ for (const { seq, event } of pending) {
419
+ const events = { [seq]: event }
420
+ const plans = core.planGuardStopShadows(events, [seq])
421
+ for (const plan of plans) {
422
+ try {
423
+ const intent = replacementIntent(session, plan.seq)
424
+ if (intent === undefined) continue
425
+ await session.append(
426
+ 'user/message',
427
+ plan.data,
428
+ intent,
429
+ )
430
+ repaired += 1
431
+ } catch (error) {
432
+ logger?.warn?.(
433
+ 'vision-router: session guard-stop surface repair failed seq=%s error=%s',
434
+ plan.seq,
435
+ boundedMessage(error),
436
+ )
437
+ }
438
+ }
439
+ }
440
+ return repaired
441
+ }
442
+
247
443
  const repairToolResultSurface = async (session) => {
444
+ if (surfaceEventFeedActive) return repairToolResultSurfaceFromFeed(session)
248
445
  if (typeof session?.append !== 'function') return 0
249
446
  const scan = pendingSurfaceScan(toolSurfaceScans, session)
250
447
  if (!scan || typeof core.planToolResultImageShadows !== 'function') return 0
251
448
  const preserve = keepToolResultImages()
449
+ const snapshot = await surfaceRepairSnapshot(session, scan.seqs[0])
450
+ if (snapshot?.readable === false) return 0
252
451
  let repaired = 0
253
452
  for (const seq of scan.seqs) {
254
- const read = await eventForSurfaceRepair(session, seq)
453
+ const read = snapshot?.readable === true
454
+ ? eventFromRepairSnapshot(session, snapshot.events, seq)
455
+ : await eventForSurfaceRepair(session, seq)
255
456
  if (!read.readable) break
256
457
  // A readable node is settled for this repair pass even when it needs no
257
458
  // replacement or append later fails; only an unread node remains pending.
@@ -282,12 +483,17 @@ export function createSessionVisionIndex({
282
483
  }
283
484
 
284
485
  const repairGuardStopSurface = async (session) => {
486
+ if (surfaceEventFeedActive) return repairGuardStopSurfaceFromFeed(session)
285
487
  if (typeof session?.append !== 'function') return 0
286
488
  const scan = pendingSurfaceScan(guardSurfaceScans, session)
287
489
  if (!scan || typeof core.planGuardStopShadows !== 'function') return 0
490
+ const snapshot = await surfaceRepairSnapshot(session, scan.seqs[0])
491
+ if (snapshot?.readable === false) return 0
288
492
  let repaired = 0
289
493
  for (const seq of scan.seqs) {
290
- const read = await eventForSurfaceRepair(session, seq)
494
+ const read = snapshot?.readable === true
495
+ ? eventFromRepairSnapshot(session, snapshot.events, seq)
496
+ : await eventForSurfaceRepair(session, seq)
291
497
  if (!read.readable) break
292
498
  scan.advance()
293
499
  const events = []
@@ -333,6 +539,8 @@ export function createSessionVisionIndex({
333
539
 
334
540
  return Object.freeze({
335
541
  recordAttachments,
542
+ recordSessionEvent,
543
+ activateSurfaceEventFeed,
336
544
  lookupAttachment,
337
545
  resolveAttachment,
338
546
  resolveAttachments,
@@ -344,8 +552,9 @@ export function createSessionVisionIndex({
344
552
 
345
553
  /**
346
554
  * Intercept core's pre-step registration and decorate only its downstream
347
- * `next()` result. Production passes `options.index`; direct/test callers get
348
- * an isolated index/store unless they explicitly provide `options.stateStore`.
555
+ * `next()` result. Production also subscribes the index to DSH's post-commit
556
+ * `session/event` feed before activating feed-backed surface repair. Direct/test
557
+ * callers without a usable event feed retain the compatibility reader path.
349
558
  */
350
559
  export function installSessionVisionIndexBoundary(ctx, config, core, options = {}) {
351
560
  if (!isObject(ctx)) return ctx
@@ -363,6 +572,25 @@ export function installSessionVisionIndexBoundary(ctx, config, core, options = {
363
572
  logger: options.logger,
364
573
  })
365
574
 
575
+ try {
576
+ const on = Reflect.get(ctx, 'on', ctx)
577
+ if (
578
+ typeof on === 'function'
579
+ && typeof index.recordSessionEvent === 'function'
580
+ && typeof index.activateSurfaceEventFeed === 'function'
581
+ ) {
582
+ on.call(ctx, 'session/event', (session, event) => {
583
+ index.recordSessionEvent(session, event)
584
+ })
585
+ index.activateSurfaceEventFeed()
586
+ }
587
+ } catch (error) {
588
+ options.logger?.warn?.(
589
+ 'vision-router: session event feed unavailable; retaining compatibility surface reader error=%s',
590
+ boundedMessage(error),
591
+ )
592
+ }
593
+
366
594
  return new Proxy(ctx, {
367
595
  get(target, property) {
368
596
  if (property === 'on') {
@@ -3,7 +3,17 @@ import { createSessionTurnResolver } from './session-turn-resolver.js'
3
3
 
4
4
  function legacyTurnNumberOf(session) {
5
5
  try {
6
- const events = session?.events
6
+ // dsh 0.1.2-alpha.4 removed the bare `session.events` array in favor of
7
+ // `session.snapshotEvents()`; mirror legacySessionEvents
8
+ // (session-vision-index.js), which prefers the snapshot when both surfaces
9
+ // exist — transitional Hosts keep the bare array but leave it stale, so a
10
+ // bare-array-first read would resolve a stale turn on them.
11
+ let events
12
+ if (typeof session?.snapshotEvents === 'function') {
13
+ events = session.snapshotEvents()
14
+ } else {
15
+ events = session?.events
16
+ }
7
17
  if (!Array.isArray(events)) return 0
8
18
  const last = events.findLast((event) => event && event.type === 'turn/start')
9
19
  return last && Number.isInteger(last.data?.turn) ? last.data.turn : 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-router",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Eyes for text-only DeepSeek Harness agents: built-in free vision chain (no key) + pixel-level vision tools (Q&A, grounding, crop, pixel diff, colors, OCR, SVG trace, cutout, screenshots). One-command install, no Python, image turns work like ordinary tool-calling turns.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,12 +70,12 @@
70
70
  "sharp": "^0.35.4"
71
71
  },
72
72
  "scripts": {
73
- "test": "node --test tests/index-modularization.test.js tests/adapter-prepare-call-compat.test.js tests/core.test.js tests/issue-374-delegated-call-config.test.js tests/grounding-coordinate-frame.test.js tests/grounding-coordinate-runtime.test.js tests/capability-advisory.test.js tests/vision-execution-policy.test.js tests/vision-backend-diagnostics.test.js tests/live-model-discovery.test.js tests/live-model-credential-resolution.test.js tests/vision-model-registry.test.js tests/live-model-client-stability.test.js tests/providers-persistence.test.js tests/provider-directory-fallback.test.js tests/model-catalog-policy.test.js tests/settings-ia-client-prelude.test.js tests/settings-ia-fj.test.js tests/settings-ia-acceptance-regression.test.js tests/settings-migration.test.js tests/settings-section-order.test.js tests/attachment-admission-policy.test.js tests/vision-resilience.test.js tests/degraded-local-evidence.test.js tests/vision-resilience-lifecycle.test.js tests/vision-breaker-readonly.test.js tests/alpha1-browser-lifecycle-integration.test.js tests/issue-367-remote-session-inject.test.js tests/vision-mode-toggle-injection.test.js tests/client.test.js tests/issue-307-regression.test.js tests/issue-284-vision-mode-toggle.test.js tests/issue-284-vision-mode-hardening.test.js tests/issue-284-attachment-id-hint.test.js tests/issue-284-model-visibility.test.js tests/issue-284-vision-selection-effort.test.js tests/client-presentation-boundary.test.js tests/clipboard-image-paste-compat.test.js tests/remote-settings-bridge.test.js tests/remote-settings-v2-contract.test.js tests/remote-settings-risk-confirmation.test.js tests/local-remote-settings-permission.test.js tests/rc6-real-settings-persistence.test.js tests/artifact-path-security.test.js tests/http-compat.test.js tests/catalog-corrections.test.js tests/update-check.test.js tests/update-check-signal-lifecycle.test.js tests/self-update.test.js tests/doctor.test.js tests/doctor-cli.test.js tests/legacy-session-repair.test.js tests/profile-pnpm-diagnostics.test.js tests/doctor-v2.test.js tests/doctor-cli-v2.test.js tests/doctor-runtime.test.js tests/dsh-host-capabilities.test.js tests/doctor-host-capabilities.test.js tests/session-repair-v2.test.js tests/bundle-defaults.test.js tests/file-logger.test.js tests/official-deepseek-catalog.test.js tests/replay-delegation.test.js tests/twin-image-capability-fallback.test.js tests/twin-image-custom-wrapper.test.js tests/adversarial-hardening.test.js tests/vision-adversarial-hardening-v2.test.js tests/vision-capability-benchmark-visual-proof.test.js tests/wrapper-directory.test.js tests/logging-ui.test.js tests/manifest-dependencies.test.js tests/structured-bootstrap.test.js tests/structured-bootstrap-gate.test.js tests/vision-model-guidance-contract.test.js tests/local-ollama.test.js tests/ollama-cold-start.test.js tests/ollama-cold-start-loaded.test.js tests/zero-regression-gate.test.js tests/runtime-e2e.test.js tests/local-vision-stabilizer.test.js tests/local-connection-probe.test.js tests/repetition-guard.test.js tests/guide-scroll-gate.test.js tests/settings-scroll-jank-regression.test.js tests/android-attachment-compat.test.js tests/free-cloud-first.test.js tests/rc6-rc7-compat.test.js tests/native-image-coexistence.test.js tests/issue-276-session-image-ownership.test.js tests/issue-276-entry-policy-bridge.test.js tests/issue-276-policy-hardening.test.js tests/issue-289-native-nonintervention.test.js tests/issue-512-tool-restriction.test.js tests/pi-ai-bridge-wire-compat.test.js tests/mixed-router.test.js tests/depth-tier.test.js tests/depth-quota-behavior.test.js tests/structured-flow-hardening.test.js tests/structured-flow-adversarial.test.js tests/vision-budget-activation.test.js tests/runtime-boundary-fixes.test.js tests/security-resource-boundaries.test.js tests/security-property-fuzz.test.js tests/security-adversarial-fuzz-contract.test.js tests/runtime-i18n.test.js tests/tesseract-adaptive-ocr.test.js tests/tesseract-node24-boot.test.js tests/session-vision-state.test.js tests/session-vision-state-integration.test.js tests/image-resource-governor.test.js tests/resource-retention-lifecycle.test.js tests/pixel-diff-stream.test.js tests/large-image-resource-integration.test.js tests/structured-guard-idempotency.test.js tests/vision-routing-product.test.js tests/vision-runtime-performance.test.js tests/vision-routing-settings-prelude.test.js tests/v2-settings-ia-integration.test.js tests/vision-capability-router.test.js tests/vision-capability-reference.test.js tests/vision-capability-benchmark.test.js tests/vision-capability-grounding-proof-contract.test.js tests/vision-capability-probe.test.js tests/vision-capability-axis-freshness.test.js tests/vision-capability-shadow-health.test.js tests/vision-capability-benchmark-service.test.js tests/vision-capability-preflight-hardening.test.js tests/vision-capability-benchmark-contract.test.js tests/vision-capability-benchmark-client.test.js tests/vision-capability-benchmark-client-productization.test.js tests/vision-capability-adapter-route.test.js tests/vision-background-benchmark.test.js tests/vision-background-lifecycle.test.js tests/vision-background-benchmark-productization.test.js tests/vision-capability-benchmark-budget.test.js tests/vision-image-input-verdict.test.js tests/vision-capability-failure-classification.test.js tests/vision-capability-gate-client.test.js tests/v2-product-review-fixes.test.js tests/qa-top5-reliability.test.js tests/qa-screenshot-runtime.test.js tests/qa-runtime-identity.test.js tests/qa-turn-budget-cancellation.test.js tests/qa-cancellation-publication.test.js tests/qa-reliability-tail.test.js tests/qa-endpoint-route-alias.test.js tests/settings-native-card-semantics.test.js tests/settings-native-card-lazy-data.test.js tests/settings-invalid-staged-dirty.test.js tests/vision-toggle-root-hardening.test.js && node --test tests/vision-backend-runtime-policy.test.js",
73
+ "test": "node --test tests/index-modularization.test.js tests/adapter-prepare-call-compat.test.js tests/core.test.js tests/issue-374-delegated-call-config.test.js tests/grounding-coordinate-frame.test.js tests/grounding-coordinate-runtime.test.js tests/capability-advisory.test.js tests/vision-execution-policy.test.js tests/vision-backend-diagnostics.test.js tests/live-model-discovery.test.js tests/live-model-credential-resolution.test.js tests/vision-model-registry.test.js tests/live-model-client-stability.test.js tests/providers-persistence.test.js tests/provider-directory-fallback.test.js tests/model-catalog-policy.test.js tests/settings-ia-client-prelude.test.js tests/settings-ia-fj.test.js tests/settings-ia-acceptance-regression.test.js tests/settings-migration.test.js tests/settings-section-order.test.js tests/attachment-admission-policy.test.js tests/vision-resilience.test.js tests/degraded-local-evidence.test.js tests/vision-resilience-lifecycle.test.js tests/vision-breaker-readonly.test.js tests/alpha1-browser-lifecycle-integration.test.js tests/issue-367-remote-session-inject.test.js tests/vision-mode-toggle-injection.test.js tests/client.test.js tests/issue-307-regression.test.js tests/issue-284-vision-mode-toggle.test.js tests/issue-284-vision-mode-hardening.test.js tests/issue-284-attachment-id-hint.test.js tests/issue-284-model-visibility.test.js tests/issue-284-vision-selection-effort.test.js tests/client-presentation-boundary.test.js tests/clipboard-image-paste-compat.test.js tests/remote-settings-bridge.test.js tests/remote-settings-v2-contract.test.js tests/remote-settings-risk-confirmation.test.js tests/local-remote-settings-permission.test.js tests/rc6-real-settings-persistence.test.js tests/artifact-path-security.test.js tests/http-compat.test.js tests/catalog-corrections.test.js tests/update-check.test.js tests/update-check-signal-lifecycle.test.js tests/self-update.test.js tests/doctor.test.js tests/doctor-cli.test.js tests/legacy-session-repair.test.js tests/profile-pnpm-diagnostics.test.js tests/doctor-v2.test.js tests/doctor-cli-v2.test.js tests/doctor-runtime.test.js tests/dsh-host-capabilities.test.js tests/doctor-host-capabilities.test.js tests/session-repair-v2.test.js tests/bundle-defaults.test.js tests/file-logger.test.js tests/official-deepseek-catalog.test.js tests/replay-delegation.test.js tests/twin-image-capability-fallback.test.js tests/twin-image-custom-wrapper.test.js tests/adversarial-hardening.test.js tests/vision-adversarial-hardening-v2.test.js tests/vision-capability-benchmark-visual-proof.test.js tests/wrapper-directory.test.js tests/logging-ui.test.js tests/manifest-dependencies.test.js tests/structured-bootstrap.test.js tests/structured-bootstrap-gate.test.js tests/vision-model-guidance-contract.test.js tests/local-ollama.test.js tests/ollama-cold-start.test.js tests/ollama-cold-start-loaded.test.js tests/zero-regression-gate.test.js tests/runtime-e2e.test.js tests/local-vision-stabilizer.test.js tests/local-connection-probe.test.js tests/repetition-guard.test.js tests/guide-scroll-gate.test.js tests/settings-scroll-jank-regression.test.js tests/android-attachment-compat.test.js tests/free-cloud-first.test.js tests/rc6-rc7-compat.test.js tests/native-image-coexistence.test.js tests/issue-276-session-image-ownership.test.js tests/issue-276-entry-policy-bridge.test.js tests/issue-276-policy-hardening.test.js tests/issue-289-native-nonintervention.test.js tests/issue-512-tool-restriction.test.js tests/pi-ai-bridge-wire-compat.test.js tests/fetch-wrapper-lifecycle.test.js tests/fetch-wrapper-composition.test.js tests/mixed-router.test.js tests/depth-tier.test.js tests/depth-quota-behavior.test.js tests/structured-flow-hardening.test.js tests/structured-flow-adversarial.test.js tests/vision-budget-activation.test.js tests/runtime-boundary-fixes.test.js tests/security-resource-boundaries.test.js tests/security-property-fuzz.test.js tests/security-adversarial-fuzz-contract.test.js tests/runtime-i18n.test.js tests/tesseract-adaptive-ocr.test.js tests/tesseract-node24-boot.test.js tests/session-vision-state.test.js tests/session-vision-state-integration.test.js tests/session-vision-event-feed.test.js tests/image-resource-governor.test.js tests/resource-retention-lifecycle.test.js tests/pixel-diff-stream.test.js tests/large-image-resource-integration.test.js tests/structured-guard-idempotency.test.js tests/vision-routing-product.test.js tests/vision-runtime-performance.test.js tests/vision-routing-settings-prelude.test.js tests/v2-settings-ia-integration.test.js tests/vision-capability-router.test.js tests/vision-capability-reference.test.js tests/vision-capability-benchmark.test.js tests/vision-capability-grounding-proof-contract.test.js tests/vision-capability-probe.test.js tests/vision-capability-axis-freshness.test.js tests/vision-capability-shadow-health.test.js tests/vision-capability-benchmark-service.test.js tests/vision-capability-preflight-hardening.test.js tests/vision-capability-benchmark-contract.test.js tests/vision-capability-benchmark-client.test.js tests/vision-capability-benchmark-client-productization.test.js tests/vision-capability-adapter-route.test.js tests/vision-background-benchmark.test.js tests/vision-background-lifecycle.test.js tests/vision-background-benchmark-productization.test.js tests/vision-capability-benchmark-budget.test.js tests/vision-image-input-verdict.test.js tests/vision-capability-failure-classification.test.js tests/vision-capability-gate-client.test.js tests/v2-product-review-fixes.test.js tests/qa-top5-reliability.test.js tests/qa-screenshot-runtime.test.js tests/qa-runtime-identity.test.js tests/qa-turn-budget-cancellation.test.js tests/qa-cancellation-publication.test.js tests/qa-reliability-tail.test.js tests/qa-endpoint-route-alias.test.js tests/settings-native-card-semantics.test.js tests/settings-native-card-lazy-data.test.js tests/settings-invalid-staged-dirty.test.js tests/vision-toggle-root-hardening.test.js && node --test tests/vision-backend-runtime-policy.test.js",
74
74
  "test:core": "node --test tests/core.test.js tests/degraded-local-evidence.test.js tests/runtime-e2e.test.js tests/structured-bootstrap.test.js tests/structured-flow-hardening.test.js tests/structured-flow-adversarial.test.js tests/vision-execution-policy.test.js tests/vision-backend-runtime-policy.test.js",
75
75
  "test:routing": "node --test tests/issue-374-delegated-call-config.test.js tests/twin-image-capability-fallback.test.js tests/twin-image-custom-wrapper.test.js tests/vision-routing-product.test.js tests/vision-capability-router.test.js tests/vision-capability-probe.test.js tests/vision-capability-shadow-health.test.js tests/vision-runtime-performance.test.js tests/vision-background-benchmark.test.js tests/vision-image-input-verdict.test.js",
76
76
  "test:session": "node --test tests/session-vision-state.test.js tests/session-vision-state-integration.test.js tests/replay-delegation.test.js tests/legacy-session-repair.test.js tests/session-repair-v2.test.js tests/issue-276-session-image-ownership.test.js",
77
77
  "test:resources": "node --test tests/artifact-path-security.test.js tests/image-resource-governor.test.js tests/resource-retention-lifecycle.test.js tests/pixel-diff-stream.test.js tests/large-image-resource-integration.test.js tests/qa-turn-budget-cancellation.test.js tests/qa-cancellation-publication.test.js",
78
- "test:compat": "node --test tests/adapter-prepare-call-compat.test.js tests/attachment-admission-policy.test.js tests/rc6-rc7-compat.test.js tests/rc6-real-settings-persistence.test.js tests/android-attachment-compat.test.js tests/http-compat.test.js tests/pi-ai-bridge-wire-compat.test.js tests/native-image-coexistence.test.js tests/tesseract-node24-boot.test.js tests/dsh-host-capabilities.test.js",
78
+ "test:compat": "node --test tests/adapter-prepare-call-compat.test.js tests/attachment-admission-policy.test.js tests/rc6-rc7-compat.test.js tests/rc6-real-settings-persistence.test.js tests/android-attachment-compat.test.js tests/http-compat.test.js tests/pi-ai-bridge-wire-compat.test.js tests/fetch-wrapper-lifecycle.test.js tests/fetch-wrapper-composition.test.js tests/native-image-coexistence.test.js tests/tesseract-node24-boot.test.js tests/dsh-host-capabilities.test.js",
79
79
  "test:web": "node --test tests/alpha1-browser-lifecycle-integration.test.js tests/issue-367-remote-session-inject.test.js tests/vision-mode-toggle-injection.test.js tests/client.test.js tests/client-presentation-boundary.test.js tests/clipboard-image-paste-compat.test.js tests/settings-ia-client-prelude.test.js tests/settings-ia-acceptance-regression.test.js tests/remote-settings-bridge.test.js tests/remote-settings-v2-contract.test.js tests/local-remote-settings-permission.test.js tests/v2-settings-ia-integration.test.js tests/logging-ui.test.js",
80
80
  "test:contract": "node --test tests/manifest-dependencies.test.js tests/bundle-defaults.test.js tests/zero-regression-gate.test.js tests/issue-289-native-nonintervention.test.js tests/issue-374-delegated-call-config.test.js tests/runtime-boundary-fixes.test.js tests/security-adversarial-fuzz-contract.test.js tests/runtime-i18n.test.js tests/adapter-prepare-call-compat.test.js tests/rc6-rc7-compat.test.js tests/attachment-admission-policy.test.js tests/dsh-host-capabilities.test.js tests/doctor-host-capabilities.test.js tests/structured-bootstrap.test.js tests/structured-bootstrap-gate.test.js",
81
81
  "test:grounding": "node --test tests/grounding-coordinate-frame.test.js tests/grounding-coordinate-runtime.test.js tests/vision-capability-grounding-proof-contract.test.js",