dsh-vision-router 2.2.8 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +16 -7
  2. package/README.zh.md +16 -7
  3. package/docs/architecture/2.3.0-compat-retirement.md +147 -0
  4. package/docs/architecture/2.3.0-execution-plan.md +410 -0
  5. package/docs/architecture/2.3.0-final-audit.md +279 -0
  6. package/docs/architecture/2.3.0-reopened-plan.md +720 -0
  7. package/docs/architecture/3.0-architecture-plan.md +252 -0
  8. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  9. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  10. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  11. package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
  12. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  13. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  14. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  15. package/docs/architecture/3.0-r7-public-api.md +151 -0
  16. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  17. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  18. package/docs/architecture/compat-inventory.md +26 -34
  19. package/docs/architecture/dsh-compatibility-matrix.md +65 -54
  20. package/docs/architecture/dsh-support-window.md +30 -22
  21. package/docs/architecture/final-closure-audit.md +2 -0
  22. package/docs/releases/v2.3.0.md +53 -0
  23. package/docs/releases/v3.0.0.md +85 -0
  24. package/entry.js +9 -57
  25. package/index.js +268 -761
  26. package/lib/agent-request-route-authority.js +1 -20
  27. package/lib/catalog-corrections.js +1 -2
  28. package/lib/client-maintenance-actions.js +145 -0
  29. package/lib/client-presentation-boundary-main.js +75 -17
  30. package/lib/client-presentation-boundary.js +53 -28
  31. package/lib/client.js +246 -144
  32. package/lib/core-apply-lifecycle.js +41 -0
  33. package/lib/core-cache.js +87 -0
  34. package/lib/core-primitives.js +91 -278
  35. package/lib/core-vision-surface-domain.js +63 -0
  36. package/lib/core-vision-surface.js +11 -77
  37. package/lib/delegated-call-config.js +15 -24
  38. package/lib/desktop-screenshot-tool.js +262 -0
  39. package/lib/doctor-cli.js +0 -0
  40. package/lib/doctor-runtime.js +4 -1
  41. package/lib/dsh-contract-compat.js +37 -256
  42. package/lib/dsh-host-capabilities.js +3 -9
  43. package/lib/dsh-settings-017-compat.js +17 -10
  44. package/lib/dsh-support-window.js +3 -3
  45. package/lib/file-logger.js +8 -3
  46. package/lib/grounding-coordinate-runtime.js +1 -2
  47. package/lib/http-body-limit.js +97 -79
  48. package/lib/http-compat.js +1 -2
  49. package/lib/image-offload-compat.js +30 -21
  50. package/lib/image-resource-governor.js +210 -228
  51. package/lib/legacy-global-proxy-boundary.js +46 -3
  52. package/lib/live-model-client-prelude.js +12 -4
  53. package/lib/live-model-discovery.js +134 -43
  54. package/lib/local-remote-settings-permission.js +33 -7
  55. package/lib/local-vision-stabilizer.js +31 -17
  56. package/lib/maintenance-routes.js +134 -0
  57. package/lib/native-image-coexistence.js +15 -3
  58. package/lib/ollama-cold-start.js +87 -23
  59. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  60. package/lib/proxy-dispatcher-pool.js +151 -92
  61. package/lib/proxy-routing.js +75 -51
  62. package/lib/proxy-url-compat.js +3 -10
  63. package/lib/public-config.js +36 -0
  64. package/lib/public-entry.d.ts +148 -0
  65. package/lib/public-entry.js +19 -11
  66. package/lib/remote-settings-bridge.js +11 -0
  67. package/lib/remote-settings-risk-client-boundary.js +155 -0
  68. package/lib/remote-settings-risk-confirmation.js +17 -105
  69. package/lib/replay-delegation.js +12 -7
  70. package/lib/replay-envelope-v2-compat.js +12 -7
  71. package/lib/runtime-composition.js +127 -34
  72. package/lib/runtime-i18n-boundary.js +10 -11
  73. package/lib/screenshot-source-boundary.js +2 -2
  74. package/lib/session-affinity-runtime.js +113 -97
  75. package/lib/session-affinity.js +89 -76
  76. package/lib/session-event-history-compat.js +30 -0
  77. package/lib/session-message-source-compat.js +7 -3
  78. package/lib/session-query-readers.js +263 -0
  79. package/lib/session-surface-compat.js +30 -31
  80. package/lib/session-surface-policy-domain.js +85 -0
  81. package/lib/session-surface-policy.js +2 -111
  82. package/lib/session-turn-resolver.js +2 -6
  83. package/lib/session-vision-attachment-index.js +143 -0
  84. package/lib/session-vision-delegation-boundary.js +34 -0
  85. package/lib/session-vision-delegation.js +22 -0
  86. package/lib/session-vision-index.js +179 -116
  87. package/lib/session-vision-mode-authority.js +229 -192
  88. package/lib/session-vision-mode-boundary.js +65 -22
  89. package/lib/session-vision-policy-domain.js +304 -0
  90. package/lib/session-vision-policy.js +104 -0
  91. package/lib/session-vision-state.js +368 -378
  92. package/lib/settings-client-017-compat.js +9 -7
  93. package/lib/settings-client-loader-lifecycle.js +129 -0
  94. package/lib/sharp-runtime.js +0 -7
  95. package/lib/structured-flow-hardening.js +25 -6
  96. package/lib/tesseract-exec-compat.js +9 -1
  97. package/lib/turn-budget-context.js +47 -41
  98. package/lib/update-check.js +11 -2
  99. package/lib/vision-attachment-handle-runtime.js +176 -156
  100. package/lib/vision-backend-runtime-policy.js +19 -2
  101. package/lib/vision-background-benchmark.js +41 -4
  102. package/lib/vision-background-failure-policy.js +41 -41
  103. package/lib/vision-background-stop-store-core.js +217 -0
  104. package/lib/vision-background-stop-store.js +8 -179
  105. package/lib/vision-capability-benchmark-service.js +1 -0
  106. package/lib/vision-capability-benchmark.js +11 -61
  107. package/lib/vision-capability-evidence.js +35 -0
  108. package/lib/vision-capability-fingerprint.js +72 -0
  109. package/lib/vision-capability-identity.js +116 -61
  110. package/lib/vision-capability-router.js +13 -33
  111. package/lib/vision-chunk-assembler.js +95 -0
  112. package/lib/vision-diagnostics-routes.js +246 -0
  113. package/lib/vision-evidence-guidance.js +3 -1
  114. package/lib/vision-execution-order-apply.js +45 -55
  115. package/lib/vision-execution-order.js +19 -19
  116. package/lib/vision-execution-policy.js +12 -7
  117. package/lib/vision-model-registry.js +30 -17
  118. package/lib/vision-provider-transport.js +137 -176
  119. package/lib/vision-routing-authority.js +47 -48
  120. package/lib/vision-routing-product.js +26 -29
  121. package/lib/vision-routing-settings-prelude.js +8 -3
  122. package/lib/vision-runtime-performance-store.js +166 -0
  123. package/lib/vision-runtime-performance.js +13 -142
  124. package/lib/vision-toggle-root-hardening.js +22 -5
  125. package/lib/vision-tool-runtime-boundary.js +104 -31
  126. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  127. package/lib/web/remote-settings-client.js +6 -9
  128. package/lib/web-capability-boundary.js +17 -7
  129. package/lib/wrapper-directory.js +14 -23
  130. package/package.json +56 -20
  131. package/lib/android-attachment-compat.js +0 -244
  132. package/lib/settings-client-rc8-lifecycle.js +0 -397
@@ -1,164 +1,192 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks'
2
-
3
- const VISION_ROUTER_ADAPTER_OWNER = Symbol.for('dsh-vision-router.adapter-owner')
4
- const VISION_ROUTER_OWNERSHIP = 'vision-router-owned'
5
- const DEFAULT_WRAPPER_ROUTE = 'deepseek-vision'
6
- const DEFAULT_CHAIN_ROUTE = 'vision-chain'
7
- const DEEPSEEK_SOURCE = 'deepseek-official'
8
-
9
- const authorityTurn = new AsyncLocalStorage()
10
- const agentAuthorities = new WeakMap()
11
- const assemblyAuthorities = new WeakMap()
12
-
13
- function isObject(value) {
14
- return value !== null && typeof value === 'object' && !Array.isArray(value)
15
- }
16
-
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { parseSessionVisionPolicy } from './session-vision-policy.js';
3
+ const VISION_ROUTER_ADAPTER_OWNER = Symbol.for('dsh-vision-router.adapter-owner');
4
+ const VISION_ROUTER_OWNERSHIP = 'vision-router-owned';
5
+ const DEFAULT_WRAPPER_ROUTE = 'deepseek-vision';
6
+ const DEFAULT_CHAIN_ROUTE = 'vision-chain';
7
+ const DEEPSEEK_SOURCE = 'deepseek-official';
8
+ const authorityTurn = new AsyncLocalStorage();
9
+ const agentAuthorities = new WeakMap();
10
+ const assemblyAuthorities = new WeakMap();
11
+ function objectRecord(value) {
12
+ return value !== null && (typeof value === 'object' || typeof value === 'function')
13
+ ? value
14
+ : undefined;
15
+ }
16
+ function property(value, key) {
17
+ return objectRecord(value)?.[key];
18
+ }
17
19
  function isWeakKey(value) {
18
- return (typeof value === 'object' && value !== null) || typeof value === 'function'
20
+ return (typeof value === 'object' && value !== null) || typeof value === 'function';
19
21
  }
20
-
21
22
  function routeFrom(value) {
22
- if (!isObject(value)) return undefined
23
- const provider = typeof value.provider === 'string' ? value.provider.trim() : ''
24
- const model = typeof value.model === 'string' ? value.model.trim() : ''
25
- return provider !== '' && model !== '' ? { provider, model } : undefined
23
+ const record = objectRecord(value);
24
+ if (!record || Array.isArray(value))
25
+ return undefined;
26
+ const provider = typeof record.provider === 'string' ? record.provider.trim() : '';
27
+ const model = typeof record.model === 'string' ? record.model.trim() : '';
28
+ return provider !== '' && model !== '' ? { provider, model } : undefined;
29
+ }
30
+ function contextGet(ctx, name) {
31
+ const record = objectRecord(ctx);
32
+ const get = record?.get;
33
+ if (typeof get !== 'function')
34
+ return undefined;
35
+ try {
36
+ return get.call(ctx, name);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
26
41
  }
27
-
28
42
  function selectionProjection(ctx, session) {
29
- let projections
30
- try {
31
- projections = ctx?.sessionProjections
32
- } catch {
33
- projections = undefined
34
- }
35
- if (!projections) {
43
+ // sessionProjections is an optional Host capability, not a declared plugin
44
+ // dependency. Cordis service properties throw when read outside inject(), so
45
+ // capability detection must use Context#get rather than property access.
46
+ const projections = contextGet(ctx, 'sessionProjections');
47
+ const stateOf = property(projections, 'stateOf');
48
+ if (typeof stateOf !== 'function')
49
+ return undefined;
36
50
  try {
37
- projections = ctx?.get?.('sessionProjections')
38
- } catch {
39
- projections = undefined
40
- }
41
- }
42
- if (!projections || typeof projections.stateOf !== 'function') return undefined
43
- try {
44
- const state = projections.stateOf(session, 'modelSelection')
45
- if (!isObject(state)) return undefined
46
- return routeFrom(state.pending ?? state.next ?? state.lastUsed)
47
- } catch {
48
- return undefined
49
- }
50
- }
51
-
51
+ const state = stateOf.call(projections, session, 'modelSelection');
52
+ const record = objectRecord(state);
53
+ if (!record || Array.isArray(state))
54
+ return undefined;
55
+ return routeFrom(record.pending ?? record.next ?? record.lastUsed);
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
52
61
  function fallbackRoute(session, agent) {
53
- try {
54
- const header = typeof session?.requestHeader === 'function' ? session.requestHeader() : undefined
55
- const direct = routeFrom(header?.config)
56
- if (direct) return direct
57
- } catch {
58
- // Cold or partially restored Sessions may not expose a request header yet.
59
- }
60
-
61
- const candidates = [
62
- agent?.options?.config,
63
- agent?.options,
64
- agent?.config,
65
- session?.agent?.options?.config,
66
- session?.agent?.options,
67
- session?.options?.config,
68
- session?.options,
69
- session?.config,
70
- session?.header?.config,
71
- session?.header,
72
- ]
73
- for (const candidate of candidates) {
74
- const route = routeFrom(candidate)
75
- if (route) return route
76
- }
77
- return undefined
78
- }
79
-
62
+ try {
63
+ const requestHeader = property(session, 'requestHeader');
64
+ const header = typeof requestHeader === 'function'
65
+ ? requestHeader.call(session)
66
+ : undefined;
67
+ const direct = routeFrom(property(header, 'config'));
68
+ if (direct)
69
+ return direct;
70
+ }
71
+ catch {
72
+ // Cold or partially restored Sessions may not expose a request header yet.
73
+ }
74
+ const agentOptions = property(agent, 'options');
75
+ const sessionAgent = property(session, 'agent');
76
+ const sessionAgentOptions = property(sessionAgent, 'options');
77
+ const sessionOptions = property(session, 'options');
78
+ const sessionHeader = property(session, 'header');
79
+ const candidates = [
80
+ property(agentOptions, 'config'),
81
+ agentOptions,
82
+ property(agent, 'config'),
83
+ property(sessionAgentOptions, 'config'),
84
+ sessionAgentOptions,
85
+ property(sessionOptions, 'config'),
86
+ sessionOptions,
87
+ property(session, 'config'),
88
+ property(sessionHeader, 'config'),
89
+ sessionHeader,
90
+ ];
91
+ for (const candidate of candidates) {
92
+ const route = routeFrom(candidate);
93
+ if (route)
94
+ return route;
95
+ }
96
+ return undefined;
97
+ }
80
98
  export function effectiveSessionModelSelection(ctx, agent) {
81
- const session = agent?.session
82
- if (!session) return undefined
83
- return selectionProjection(ctx, session) ?? fallbackRoute(session, agent)
99
+ const session = property(agent, 'session');
100
+ if (!session)
101
+ return undefined;
102
+ return selectionProjection(ctx, session) ?? fallbackRoute(session, agent);
84
103
  }
85
-
86
104
  function liveConfig(ctx, fallback) {
87
- try {
88
- const settings = ctx?.get?.('settings')
89
- const value = settings?.get?.('vision-router')
90
- if (isObject(value)) return value
91
- } catch {
92
- // Composition config remains authoritative while Settings is unavailable.
93
- }
94
- return isObject(fallback) ? fallback : {}
95
- }
96
-
105
+ try {
106
+ const settings = contextGet(ctx, 'settings');
107
+ const get = property(settings, 'get');
108
+ const value = typeof get === 'function'
109
+ ? get.call(settings, 'vision-router')
110
+ : undefined;
111
+ const record = objectRecord(value);
112
+ if (record && !Array.isArray(value))
113
+ return record;
114
+ }
115
+ catch {
116
+ // Composition config remains authoritative while Settings is unavailable.
117
+ }
118
+ const record = objectRecord(fallback);
119
+ return record && !Array.isArray(fallback) ? record : {};
120
+ }
97
121
  function registrationLookup(ctx) {
98
- const registration = ctx?.llm?.registration
99
- return typeof registration === 'function' ? registration : undefined
122
+ const llm = property(ctx, 'llm');
123
+ const registration = property(llm, 'registration');
124
+ return typeof registration === 'function'
125
+ ? { owner: llm, lookup: registration }
126
+ : undefined;
100
127
  }
101
-
102
128
  function currentAdapter(ctx, provider) {
103
- const registration = registrationLookup(ctx)
104
- if (!registration) return undefined
105
- try {
106
- return registration.call(ctx.llm, provider)?.adapter
107
- } catch {
108
- return undefined
109
- }
110
- }
111
-
129
+ const registration = registrationLookup(ctx);
130
+ if (!registration)
131
+ return undefined;
132
+ try {
133
+ return property(registration.lookup.call(registration.owner, provider), 'adapter');
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
112
139
  function adapterOwnedByVisionRouter(adapter) {
113
- if (!isWeakKey(adapter)) return false
114
- try {
115
- return adapter[VISION_ROUTER_ADAPTER_OWNER] !== undefined
116
- } catch {
117
- return false
118
- }
119
- }
120
-
140
+ if (!isWeakKey(adapter))
141
+ return false;
142
+ try {
143
+ return objectRecord(adapter)?.[VISION_ROUTER_ADAPTER_OWNER] !== undefined;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
121
149
  function sameRoute(left, right) {
122
- return !!left && !!right && left.provider === right.provider && left.model === right.model
150
+ const rightRoute = routeFrom(right);
151
+ return !!left
152
+ && !!rightRoute
153
+ && left.provider === rightRoute.provider
154
+ && left.model === rightRoute.model;
123
155
  }
124
-
125
156
  function configuredRoute(config, key, fallback) {
126
- const value = typeof config?.[key] === 'string' ? config[key].trim() : ''
127
- return value || fallback
157
+ const value = typeof config[key] === 'string' ? config[key].trim() : '';
158
+ return value || fallback;
128
159
  }
129
-
130
160
  function routeOwnedByVisionRouter(ctx, route, config, visionPolicy) {
131
- if (!route) return false
132
-
133
- const registration = registrationLookup(ctx)
134
- if (registration) {
135
- // On current DSH, the final Host-visible adapter marker is authoritative.
136
- // If the configured route was replaced by a foreign adapter, fail closed
137
- // rather than granting mode from a matching route name.
138
- return adapterOwnedByVisionRouter(currentAdapter(ctx, route.provider))
139
- }
140
-
141
- // Minimum Host shims may not expose registration(). Native-image-coexistence
142
- // already proved exact registration ownership for the same selected route;
143
- // accept only that turn-local proof, never a generic -vision suffix.
144
- if (
145
- visionPolicy?.ownership === VISION_ROUTER_OWNERSHIP &&
146
- sameRoute(route, visionPolicy.route)
147
- ) {
148
- return true
149
- }
150
-
151
- // Compatibility fallback for explicit historical routes only when the Host
152
- // offers no registration identity at all. Current DSH never reaches this.
153
- const wrapperRoute = configuredRoute(config, 'wrapperRoute', DEFAULT_WRAPPER_ROUTE)
154
- const chainRoute = configuredRoute(config, 'chainRoute', DEFAULT_CHAIN_ROUTE)
155
- if (route.provider === wrapperRoute || route.provider === chainRoute || route.provider === 'vision-http') {
156
- return true
157
- }
158
- if (config?.stealth === true && route.provider === DEEPSEEK_SOURCE) return true
159
- return false
160
- }
161
-
161
+ if (!route)
162
+ return false;
163
+ const registration = registrationLookup(ctx);
164
+ if (registration) {
165
+ // On current DSH, the final Host-visible adapter marker is authoritative.
166
+ // If the configured route was replaced by a foreign adapter, fail closed
167
+ // rather than granting mode from a matching route name.
168
+ return adapterOwnedByVisionRouter(currentAdapter(ctx, route.provider));
169
+ }
170
+ // Minimum Host shims may not expose registration(). Native-image-coexistence
171
+ // already proved exact registration ownership for the same selected route;
172
+ // accept only that turn-local proof, never a generic -vision suffix.
173
+ if (property(visionPolicy, 'ownership') === VISION_ROUTER_OWNERSHIP
174
+ && sameRoute(route, property(visionPolicy, 'route'))) {
175
+ return true;
176
+ }
177
+ // Compatibility fallback for explicit historical routes only when the Host
178
+ // offers no registration identity at all. Current DSH never reaches this.
179
+ const wrapperRoute = configuredRoute(config, 'wrapperRoute', DEFAULT_WRAPPER_ROUTE);
180
+ const chainRoute = configuredRoute(config, 'chainRoute', DEFAULT_CHAIN_ROUTE);
181
+ if (route.provider === wrapperRoute
182
+ || route.provider === chainRoute
183
+ || route.provider === 'vision-http') {
184
+ return true;
185
+ }
186
+ if (config.stealth === true && route.provider === DEEPSEEK_SOURCE)
187
+ return true;
188
+ return false;
189
+ }
162
190
  /**
163
191
  * Resolve the one authoritative Vision-mode snapshot for the next Agent step.
164
192
  *
@@ -168,36 +196,46 @@ function routeOwnedByVisionRouter(ctx, route, config, visionPolicy) {
168
196
  * This prevents a stale ON header from granting tools after the user switched
169
197
  * Vision off (and the inverse when turning it back on).
170
198
  */
171
- export function resolveSessionVisionModeAuthority(
172
- ctx,
173
- agent,
174
- fallbackConfig = {},
175
- options = {},
176
- ) {
177
- const route = effectiveSessionModelSelection(ctx, agent)
178
- const config = liveConfig(ctx, fallbackConfig)
179
- const enabled = routeOwnedByVisionRouter(ctx, route, config, options.visionPolicy)
180
- return Object.freeze({
181
- enabled,
182
- route: route ? Object.freeze({ ...route }) : undefined,
183
- reason: enabled ? 'vision-router-route' : route ? 'ordinary-route' : 'unknown-route',
184
- ...(options.turn === undefined ? {} : { turn: options.turn }),
185
- })
186
- }
187
-
199
+ export function resolveSessionVisionModeAuthority(ctx, agent, fallbackConfig = {}, options = {}) {
200
+ const route = routeFrom(options.route) ?? effectiveSessionModelSelection(ctx, agent);
201
+ const sessionPolicy = parseSessionVisionPolicy(options.sessionPolicy);
202
+ const config = liveConfig(ctx, fallbackConfig);
203
+ const routeEnabled = routeOwnedByVisionRouter(ctx, route, config, options.visionPolicy);
204
+ const enabled = sessionPolicy?.enabled ?? routeEnabled;
205
+ return Object.freeze({
206
+ enabled,
207
+ route: route ? Object.freeze({ ...route }) : undefined,
208
+ reason: sessionPolicy !== undefined
209
+ ? 'session-policy'
210
+ : routeEnabled
211
+ ? 'vision-router-route'
212
+ : route
213
+ ? 'ordinary-route'
214
+ : 'unknown-route',
215
+ ...(options.turn === undefined ? {} : { turn: options.turn }),
216
+ });
217
+ }
188
218
  function normalizedAuthority(authority) {
189
- return isObject(authority)
190
- ? authority
191
- : Object.freeze({ enabled: false, reason: 'unknown-route' })
219
+ const record = objectRecord(authority);
220
+ if (record && !Array.isArray(authority)) {
221
+ // Compatibility callers already provide the immutable authority envelope.
222
+ // Preserve that exact snapshot rather than rebuilding it and changing
223
+ // historical property-presence semantics.
224
+ return authority;
225
+ }
226
+ return Object.freeze({
227
+ enabled: false,
228
+ route: undefined,
229
+ reason: 'unknown-route',
230
+ });
192
231
  }
193
-
194
232
  /** Persist the immutable authority for the currently entered step. */
195
233
  export function rememberSessionVisionModeAuthorityForAgent(authority, agent) {
196
- const snapshot = normalizedAuthority(authority)
197
- if (isWeakKey(agent)) agentAuthorities.set(agent, snapshot)
198
- return snapshot
234
+ const snapshot = normalizedAuthority(authority);
235
+ if (isWeakKey(agent))
236
+ agentAuthorities.set(agent, snapshot);
237
+ return snapshot;
199
238
  }
200
-
201
239
  /**
202
240
  * Stage the authority captured by prompt assembly for exactly the immediately
203
241
  * following pre-step. Keeping this separate from the active-step snapshot is
@@ -205,40 +243,37 @@ export function rememberSessionVisionModeAuthorityForAgent(authority, agent) {
205
243
  * and an old completed-step snapshot must never masquerade as a new assembly.
206
244
  */
207
245
  export function rememberSessionVisionModeAssemblyAuthorityForAgent(authority, agent) {
208
- const snapshot = normalizedAuthority(authority)
209
- if (isWeakKey(agent)) assemblyAuthorities.set(agent, snapshot)
210
- return snapshot
246
+ const snapshot = normalizedAuthority(authority);
247
+ if (isWeakKey(agent))
248
+ assemblyAuthorities.set(agent, snapshot);
249
+ return snapshot;
211
250
  }
212
-
213
251
  /** Consume at most one prompt-assembly snapshot for the following pre-step. */
214
252
  export function takeSessionVisionModeAssemblyAuthorityForAgent(agent) {
215
- if (!isWeakKey(agent)) return undefined
216
- const snapshot = assemblyAuthorities.get(agent)
217
- assemblyAuthorities.delete(agent)
218
- return snapshot
253
+ if (!isWeakKey(agent))
254
+ return undefined;
255
+ const snapshot = assemblyAuthorities.get(agent);
256
+ assemblyAuthorities.delete(agent);
257
+ return snapshot;
219
258
  }
220
-
221
259
  /** Store one immutable step snapshot and keep it ambient while Core assembles it. */
222
260
  export function runWithSessionVisionModeAuthority(authority, agent, callback) {
223
- const snapshot = rememberSessionVisionModeAuthorityForAgent(authority, agent)
224
- return authorityTurn.run({ authority: snapshot }, callback)
261
+ const snapshot = rememberSessionVisionModeAuthorityForAgent(authority, agent);
262
+ return authorityTurn.run({ authority: snapshot }, callback);
225
263
  }
226
-
227
264
  export function currentSessionVisionModeAuthority() {
228
- return authorityTurn.getStore()?.authority
265
+ return authorityTurn.getStore()?.authority;
229
266
  }
230
-
231
267
  export function sessionVisionModeAuthorityForAgent(agent) {
232
- return isWeakKey(agent) ? agentAuthorities.get(agent) : undefined
268
+ return isWeakKey(agent) ? agentAuthorities.get(agent) : undefined;
233
269
  }
234
-
235
270
  /** Drop completed/abandoned step and staged assembly snapshots. */
236
271
  export function clearSessionVisionModeAuthorityForAgent(agent) {
237
- if (!isWeakKey(agent)) return
238
- agentAuthorities.delete(agent)
239
- assemblyAuthorities.delete(agent)
272
+ if (!isWeakKey(agent))
273
+ return;
274
+ agentAuthorities.delete(agent);
275
+ assemblyAuthorities.delete(agent);
240
276
  }
241
-
242
277
  /**
243
278
  * Execution-time fail-closed check. Real Agent steps use the prompt-assembly
244
279
  * snapshot after pre-step promotes it to the active step, so a concurrent
@@ -246,9 +281,11 @@ export function clearSessionVisionModeAuthorityForAgent(agent) {
246
281
  * current request in half. Direct/internal calls with an Agent but no captured
247
282
  * step resolve the live DSH selection synchronously.
248
283
  */
249
- export function visionModeEnabledForAgent(ctx, agent, fallbackConfig = {}) {
250
- if (!isWeakKey(agent)) return true
251
- const snapshot = agentAuthorities.get(agent)
252
- if (snapshot) return snapshot.enabled === true
253
- return resolveSessionVisionModeAuthority(ctx, agent, fallbackConfig).enabled === true
284
+ export function visionModeEnabledForAgent(ctx, agent, fallbackConfig = {}, options = {}) {
285
+ if (!isWeakKey(agent))
286
+ return true;
287
+ const snapshot = agentAuthorities.get(agent);
288
+ if (snapshot)
289
+ return snapshot.enabled === true;
290
+ return resolveSessionVisionModeAuthority(ctx, agent, fallbackConfig, options).enabled === true;
254
291
  }
@@ -1,4 +1,5 @@
1
1
  import { currentSessionVisionPolicy } from './native-image-coexistence.js'
2
+ import { userSessionVisionPolicy } from './session-vision-policy.js'
2
3
  import {
3
4
  clearSessionVisionModeAuthorityForAgent,
4
5
  rememberSessionVisionModeAssemblyAuthorityForAgent,
@@ -245,7 +246,7 @@ function projectAssemblyTools(assembly, enabled, owned) {
245
246
  return { ...assembly, tools }
246
247
  }
247
248
 
248
- function wrapTools(tools, ctx, config, owned) {
249
+ function wrapTools(tools, ctx, config, owned, sessionPolicyForAgent) {
249
250
  if (!isObject(tools)) return tools
250
251
  return new Proxy(tools, {
251
252
  get(target, property) {
@@ -272,7 +273,12 @@ function wrapTools(tools, ctx, config, owned) {
272
273
  async execute(args, exec) {
273
274
  if (
274
275
  exec?.agent &&
275
- !visionModeEnabledForAgent(ctx, exec.agent, liveConfig(ctx, config))
276
+ !visionModeEnabledForAgent(
277
+ ctx,
278
+ exec.agent,
279
+ liveConfig(ctx, config),
280
+ { sessionPolicy: sessionPolicyForAgent(exec.agent) },
281
+ )
276
282
  ) {
277
283
  throw visionModeDisabledError(definition.name)
278
284
  }
@@ -322,19 +328,47 @@ function wrapTools(tools, ctx, config, owned) {
322
328
  * It deliberately lives outside the retired legacy-core policy bridge, whose
323
329
  * identity-only closure contract remains frozen.
324
330
  */
325
- export function installSessionVisionModeBoundary(ctx, config = {}) {
331
+ export function installSessionVisionModeBoundary(ctx, config = {}, runtime = {}) {
326
332
  if (!isObject(ctx)) return { ctx, config }
327
333
  const restrictions = new WeakMap()
328
334
  const activeRestrictions = new Set()
329
335
  const assemblySnapshots = new WeakMap()
330
336
  const ownedVisionTools = new Map()
337
+ const sessionVisionPolicyStore = runtime?.sessionVisionPolicyStore
331
338
  let toolsView
332
339
 
340
+ // This boundary can install Agent-scoped restrictions whose lifetime exceeds
341
+ // the plugin generation. Establish the generation owner before registering
342
+ // prompt/event hooks that can ever create those restrictions.
343
+ try {
344
+ if (typeof ctx.effect === 'function') {
345
+ ctx.effect(
346
+ () => () => releaseAllAgentToolRestrictions(activeRestrictions),
347
+ 'vision-router: Session Vision restriction lifecycle',
348
+ )
349
+ }
350
+ } catch {
351
+ return { ctx, config }
352
+ }
353
+
354
+ const sessionPolicyForAgent = (agent) => {
355
+ const id = typeof agent?.id === 'string' ? agent.id : ''
356
+ if (id === '' || typeof sessionVisionPolicyStore?.get !== 'function') return undefined
357
+ try {
358
+ return sessionVisionPolicyStore.get(id)
359
+ } catch {
360
+ return undefined
361
+ }
362
+ }
363
+
333
364
  const resolveForAgent = (agent, options = {}) => resolveSessionVisionModeAuthority(
334
365
  ctx,
335
366
  agent,
336
367
  liveConfig(ctx, config),
337
- options,
368
+ {
369
+ ...options,
370
+ sessionPolicy: options.sessionPolicy ?? sessionPolicyForAgent(agent),
371
+ },
338
372
  )
339
373
 
340
374
  const clearAgent = (agent) => {
@@ -381,7 +415,7 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
381
415
  // Code Mode's tools:sdk are generated for real Agent-loop assemblies. The
382
416
  // returned variable is unused and never reaches the model.
383
417
  try {
384
- const systemPrompt = ctx?.systemPrompt ?? ctx?.get?.('systemPrompt')
418
+ const systemPrompt = ctx?.get?.('systemPrompt')
385
419
  if (typeof systemPrompt?.variable === 'function') {
386
420
  systemPrompt.variable(MODE_SYNC_VARIABLE, (context) => {
387
421
  captureForAssembly(context)
@@ -437,27 +471,34 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
437
471
  ctx.on?.('agent/disposed', ({ agent }) => {
438
472
  clearAgent(agent)
439
473
  releaseAgentToolRestriction(agent, restrictions, activeRestrictions)
474
+ const id = typeof agent?.id === 'string' ? agent.id : ''
475
+ if (id !== '') {
476
+ try { sessionVisionPolicyStore?.release?.(id) } catch {}
477
+ }
440
478
  })
441
- } catch {
442
- // Minimum Hosts may not expose every lifecycle event. Prompt assembly and
443
- // execution guards preserve the safety boundary without making apply fail.
444
- }
445
479
 
446
- // tools.restrict() is invoked through the long-lived Agent scope so its
447
- // disposer belongs to that scope, not to this plugin fiber. A Settings/HMR
448
- // recompose can therefore retire this boundary while the Agent survives. If
449
- // we do not release those masks here, the next boundary generation cannot
450
- // discover or dispose the old WeakMap entries and Vision tools stay hidden
451
- // even after the Session selects a Router-owned route again.
452
- try {
453
- if (typeof ctx.effect === 'function') {
454
- ctx.effect(
455
- () => () => releaseAllAgentToolRestrictions(activeRestrictions),
456
- 'vision-router: Session Vision restriction lifecycle',
480
+ // DSH persists explicit composer/model-picker intent as model/selection.
481
+ // Materialize the matching DVR Session authority from that exact durable
482
+ // event so a delegated snapshot can always be overridden by a later user
483
+ // choice. Route-derived compatibility remains the fallback until a Session
484
+ // has made an explicit selection.
485
+ ctx.on?.('session/event', (session, event) => {
486
+ if (event?.type !== 'model/selection') return
487
+ const id = typeof session?.id === 'string' ? session.id : ''
488
+ if (id === '' || typeof sessionVisionPolicyStore?.set !== 'function') return
489
+ const authority = resolveSessionVisionModeAuthority(
490
+ ctx,
491
+ { id, session },
492
+ liveConfig(ctx, config),
493
+ { route: event.data },
457
494
  )
458
- }
495
+ void sessionVisionPolicyStore
496
+ .set(id, userSessionVisionPolicy(authority.enabled))
497
+ .catch(() => {})
498
+ })
459
499
  } catch {
460
- // Agent disposal / live enable transitions still release their own masks.
500
+ // Minimum Hosts may not expose every lifecycle event. Prompt assembly and
501
+ // execution guards preserve the safety boundary without making apply fail.
461
502
  }
462
503
 
463
504
  const wrapped = new Proxy(ctx, {
@@ -469,6 +510,7 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
469
510
  target,
470
511
  config,
471
512
  ownedVisionTools,
513
+ sessionPolicyForAgent,
472
514
  )
473
515
  }
474
516
  return toolsView
@@ -493,6 +535,7 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
493
535
  {
494
536
  turn: payload?.turn,
495
537
  visionPolicy: currentSessionVisionPolicy(),
538
+ sessionPolicy: sessionPolicyForAgent(agent),
496
539
  },
497
540
  )
498
541
  syncAgentToolRestriction(