dsh-vision-router 2.0.0 → 2.1.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 (92) hide show
  1. package/README.md +33 -16
  2. package/README.zh.md +34 -17
  3. package/cordis.patch.yml +19 -9
  4. package/docs/architecture/2x-contract-ledger.md +165 -0
  5. package/docs/architecture/compat-inventory.md +121 -0
  6. package/docs/architecture/dsh-compatibility-matrix.md +57 -0
  7. package/docs/architecture/dsh-support-window.md +50 -0
  8. package/docs/architecture/final-closure-audit.md +148 -0
  9. package/docs/architecture/p2-jobs-feasibility.md +94 -0
  10. package/docs/architecture/p3-compat-retirement.md +46 -0
  11. package/docs/architecture/p3-host-native-seams.md +43 -0
  12. package/docs/architecture/p3-native-recovery-evaluation.md +65 -0
  13. package/docs/architecture/runtime-boundaries.md +124 -0
  14. package/docs/releases/v2.0.1.md +17 -0
  15. package/docs/releases/v2.1.0-support-window.md +29 -0
  16. package/docs/releases/v2.1.0.md +83 -0
  17. package/entry.js +13 -307
  18. package/index.js +85 -352
  19. package/lib/abort-signal-compat.js +191 -0
  20. package/lib/adapter-update-coalescer.js +113 -29
  21. package/lib/adversarial-hardening.js +24 -9
  22. package/lib/artifact-boundary.js +16 -175
  23. package/lib/artifact-io.js +220 -0
  24. package/lib/artifact-retention.js +305 -53
  25. package/lib/catalog-corrections.js +6 -1
  26. package/lib/client-host-compat-prelude.js +249 -0
  27. package/lib/client-presentation-boundary.js +61 -3
  28. package/lib/client.js +71 -72
  29. package/lib/core-vision-surface.js +124 -0
  30. package/lib/depth-guidance.js +114 -90
  31. package/lib/doctor-cli-p0.js +168 -0
  32. package/lib/doctor-cli.js +7 -0
  33. package/lib/doctor-vision-limits.js +72 -0
  34. package/lib/dsh-contract-compat.js +207 -39
  35. package/lib/dsh-host-capabilities.js +107 -0
  36. package/lib/dsh-support-window.js +48 -0
  37. package/lib/http-compat.js +22 -6
  38. package/lib/image-resource-governor.js +35 -10
  39. package/lib/legacy-core-vision-policy-bridge.js +25 -226
  40. package/lib/legacy-global-proxy-boundary.js +127 -0
  41. package/lib/live-model-client-prelude.js +0 -12
  42. package/lib/mixed-router.js +81 -81
  43. package/lib/public-entry.js +42 -4
  44. package/lib/runtime-composition.js +348 -0
  45. package/lib/runtime-i18n-boundary.js +683 -0
  46. package/lib/runtime-i18n-core-scope.js +56 -0
  47. package/lib/runtime-i18n-core.js +34 -0
  48. package/lib/runtime-i18n.js +121 -0
  49. package/lib/session-surface-policy.js +97 -0
  50. package/lib/session-vision-index.js +282 -0
  51. package/lib/session-vision-runtime.js +48 -0
  52. package/lib/settings-factory-lifecycle.js +144 -0
  53. package/lib/settings-ia-client-prelude.js +74 -2
  54. package/lib/settings-limit-client-prelude.js +110 -0
  55. package/lib/settings-native-card-layout.js +297 -0
  56. package/lib/settings-number-contract.js +29 -0
  57. package/lib/structured-flow-hardening.js +207 -76
  58. package/lib/tesseract-exec-compat.js +58 -32
  59. package/lib/update-check.js +17 -5
  60. package/lib/v2-settings-ia-integration.js +9 -4
  61. package/lib/vision-artifact-store.js +77 -0
  62. package/lib/vision-backend-runtime-policy.js +6 -2
  63. package/lib/vision-background-benchmark.js +130 -41
  64. package/lib/vision-background-failure-policy.js +70 -0
  65. package/lib/vision-background-stop-store.js +54 -9
  66. package/lib/vision-capability-benchmark-client.js +57 -66
  67. package/lib/vision-capability-benchmark-presentation.js +97 -0
  68. package/lib/vision-capability-benchmark-service.js +3 -3
  69. package/lib/vision-execution-order-apply.js +61 -0
  70. package/lib/vision-execution-order-plan.js +64 -0
  71. package/lib/vision-execution-order.js +38 -0
  72. package/lib/vision-limit-diagnostics.js +202 -0
  73. package/lib/vision-product-presentation.js +284 -0
  74. package/lib/vision-provider-transport.js +197 -0
  75. package/lib/vision-resilience.js +87 -45
  76. package/lib/vision-routing-evidence.js +366 -0
  77. package/lib/vision-routing-runtime.js +278 -0
  78. package/lib/vision-tool-runtime-boundary.js +27 -21
  79. package/lib/vision-turn-budget-client-prelude.js +9 -9
  80. package/lib/web/benchmark-panel.js +6 -0
  81. package/lib/web/diagnostics-panel.js +44 -0
  82. package/lib/web/index.js +60 -0
  83. package/lib/web/model-picker.js +22 -0
  84. package/lib/web/onboarding.js +6 -0
  85. package/lib/web/product-state.js +59 -0
  86. package/lib/web/remote-settings-client.js +8 -0
  87. package/lib/web/routing-section.js +6 -0
  88. package/lib/web/settings-controller.js +6 -0
  89. package/lib/web-capability-boundary.js +65 -9
  90. package/lib/windows-screenshot-dpi-compat.js +148 -0
  91. package/package.json +14 -6
  92. package/lib/vision-capability-shadow.js +0 -576
@@ -0,0 +1,220 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { lstat, mkdir, realpath, rename, unlink, writeFile } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import {
5
+ currentVisionTurnBudget,
6
+ currentVisionTurnBudgetSignal,
7
+ } from './turn-budget-context.js'
8
+ import {
9
+ isManagedArtifactRunName,
10
+ scheduleArtifactRetention,
11
+ } from './artifact-retention.js'
12
+
13
+ export const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
14
+ export const ARTIFACT_RUNS_DIR = '.runs'
15
+
16
+ function isPathInside(root, candidate) {
17
+ const relative = path.relative(root, candidate)
18
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
19
+ }
20
+
21
+ function isMissing(error) {
22
+ return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
23
+ }
24
+
25
+ function artifactAbortError() {
26
+ const error = new Error('vision-router: artifact publication aborted')
27
+ error.name = 'AbortError'
28
+ error.code = 'ABORT_ERR'
29
+ return error
30
+ }
31
+
32
+ function throwIfVisionAborted() {
33
+ if (currentVisionTurnBudgetSignal()?.aborted) throw artifactAbortError()
34
+ }
35
+
36
+ function currentArtifactRunId() {
37
+ const value = currentVisionTurnBudget()?.artifactRunId
38
+ return isManagedArtifactRunName(value) ? value : undefined
39
+ }
40
+
41
+ export function normalizeArtifactsDir(value) {
42
+ if (typeof value !== 'string' || value.trim() === '') return DEFAULT_ARTIFACTS_DIR
43
+ const raw = value.trim()
44
+ if (path.isAbsolute(raw) || path.win32.isAbsolute(raw)) return DEFAULT_ARTIFACTS_DIR
45
+ const parts = raw.split(/[\\/]+/)
46
+ if (parts.some((part) => part === '..')) return DEFAULT_ARTIFACTS_DIR
47
+ const normalized = path.normalize(raw)
48
+ if (normalized === '' || normalized === '.' || normalized === path.sep) return DEFAULT_ARTIFACTS_DIR
49
+ return normalized
50
+ }
51
+
52
+ function normalizeRelativeArtifactPath(value) {
53
+ if (typeof value !== 'string' || value.trim() === '') {
54
+ throw new Error('vision-router: artifact path must be a non-empty relative path')
55
+ }
56
+ const raw = value.trim()
57
+ if (path.isAbsolute(raw) || path.win32.isAbsolute(raw)) {
58
+ throw new Error('vision-router: artifact path must stay inside the artifacts directory')
59
+ }
60
+ const parts = raw.split(/[\\/]+/)
61
+ if (parts.some((part) => part === '..')) {
62
+ throw new Error('vision-router: artifact path must stay inside the artifacts directory')
63
+ }
64
+ const normalized = path.normalize(raw)
65
+ if (normalized === '' || normalized === '.' || normalized === path.sep) {
66
+ throw new Error('vision-router: artifact path must name a file')
67
+ }
68
+ return normalized
69
+ }
70
+
71
+ async function realpathNearestExisting(candidate, realpathImpl) {
72
+ let current = candidate
73
+ while (true) {
74
+ try {
75
+ return await realpathImpl(current)
76
+ } catch (error) {
77
+ if (!isMissing(error)) throw error
78
+ const parent = path.dirname(current)
79
+ if (parent === current) throw error
80
+ current = parent
81
+ }
82
+ }
83
+ }
84
+
85
+ async function safeLstat(target, lstatImpl) {
86
+ try {
87
+ return await lstatImpl(target)
88
+ } catch (error) {
89
+ if (isMissing(error)) return undefined
90
+ throw error
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Hardened artifact target primitive below VisionArtifactStore.
96
+ *
97
+ * P2-B gives managed tool-run artifacts their own `.runs/` namespace. The
98
+ * namespace is treated as infrastructure, not user-controlled path data: an
99
+ * existing symlink is rejected even when it points somewhere else inside the
100
+ * workspace. Artifacts outside a managed run keep their original root layout.
101
+ */
102
+ export async function resolveArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
103
+ const realpathImpl = deps.realpath ?? realpath
104
+ const mkdirImpl = deps.mkdir ?? mkdir
105
+ const lstatImpl = deps.lstat ?? lstat
106
+
107
+ throwIfVisionAborted()
108
+ const workspaceReal = await realpathImpl(path.resolve(String(workspace ?? '')))
109
+ throwIfVisionAborted()
110
+ const relativeBase = normalizeArtifactsDir(artifactsDir)
111
+ const runId = currentArtifactRunId()
112
+ const requestedTarget = normalizeRelativeArtifactPath(relativePath)
113
+ const relativeTarget = runId
114
+ ? normalizeRelativeArtifactPath(path.join(ARTIFACT_RUNS_DIR, runId, requestedTarget))
115
+ : requestedTarget
116
+ const lexicalBase = path.resolve(workspaceReal, relativeBase)
117
+ if (!isPathInside(workspaceReal, lexicalBase)) {
118
+ throw new Error('vision-router: artifactsDir must stay inside the session workspace')
119
+ }
120
+
121
+ const lexicalRunsBase = runId ? path.resolve(lexicalBase, ARTIFACT_RUNS_DIR) : undefined
122
+ if (lexicalRunsBase && !isPathInside(lexicalBase, lexicalRunsBase)) {
123
+ throw new Error('vision-router: managed artifact runs must stay inside the artifacts directory')
124
+ }
125
+ if (lexicalRunsBase) {
126
+ const before = await safeLstat(lexicalRunsBase, lstatImpl)
127
+ throwIfVisionAborted()
128
+ if (before?.isSymbolicLink?.()) {
129
+ throw new Error('vision-router: managed artifact runs directory must not be a symlink')
130
+ }
131
+ if (before !== undefined && !before.isDirectory?.()) {
132
+ throw new Error('vision-router: managed artifact runs path must be a directory')
133
+ }
134
+ }
135
+
136
+ const lexicalTarget = path.resolve(lexicalBase, relativeTarget)
137
+ if (!isPathInside(lexicalBase, lexicalTarget)) {
138
+ throw new Error('vision-router: artifact target must stay inside the artifacts directory')
139
+ }
140
+
141
+ const lexicalParent = path.dirname(lexicalTarget)
142
+ const existingAncestorReal = await realpathNearestExisting(lexicalParent, realpathImpl)
143
+ throwIfVisionAborted()
144
+ if (!isPathInside(workspaceReal, existingAncestorReal)) {
145
+ throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
146
+ }
147
+
148
+ await mkdirImpl(lexicalParent, { recursive: true })
149
+ throwIfVisionAborted()
150
+ const parentReal = await realpathImpl(lexicalParent)
151
+ const artifactsBaseReal = await realpathImpl(lexicalBase)
152
+ throwIfVisionAborted()
153
+ if (!isPathInside(workspaceReal, parentReal) || !isPathInside(workspaceReal, artifactsBaseReal)) {
154
+ throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
155
+ }
156
+
157
+ let runsBaseReal
158
+ if (lexicalRunsBase) {
159
+ const after = await safeLstat(lexicalRunsBase, lstatImpl)
160
+ throwIfVisionAborted()
161
+ if (!after?.isDirectory?.() || after.isSymbolicLink?.()) {
162
+ throw new Error('vision-router: managed artifact runs directory must be a real directory')
163
+ }
164
+ runsBaseReal = await realpathImpl(lexicalRunsBase)
165
+ throwIfVisionAborted()
166
+ if (!isPathInside(artifactsBaseReal, runsBaseReal)) {
167
+ throw new Error('vision-router: managed artifact runs directory escapes the artifacts directory')
168
+ }
169
+ }
170
+
171
+ const target = path.join(parentReal, path.basename(lexicalTarget))
172
+ const existing = await safeLstat(target, lstatImpl)
173
+ throwIfVisionAborted()
174
+ if (existing?.isDirectory?.()) {
175
+ throw new Error('vision-router: artifact target is a directory')
176
+ }
177
+ return { target, parentReal, artifactsBaseReal, runsBaseReal, existing, runId }
178
+ }
179
+
180
+ /** Hardened atomic publication primitive used by VisionArtifactStore. */
181
+ export async function writeArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
182
+ const writeFileImpl = deps.writeFile ?? writeFile
183
+ const renameImpl = deps.rename ?? rename
184
+ const unlinkImpl = deps.unlink ?? unlink
185
+ const resolved = await resolveArtifactTarget(workspace, artifactsDir, relativePath, deps)
186
+ const temp = path.join(
187
+ resolved.parentReal,
188
+ `.dsh-vision-router-${process.pid}-${randomUUID()}.tmp`,
189
+ )
190
+ let published = false
191
+ try {
192
+ throwIfVisionAborted()
193
+ await writeFileImpl(temp, data, { mode: 0o600 })
194
+ throwIfVisionAborted()
195
+ if (resolved.existing !== undefined) {
196
+ try {
197
+ await unlinkImpl(resolved.target)
198
+ } catch (error) {
199
+ if (!isMissing(error)) throw error
200
+ }
201
+ throwIfVisionAborted()
202
+ }
203
+ await renameImpl(temp, resolved.target)
204
+ published = true
205
+ if (resolved.runId) {
206
+ // New managed runs live under `.runs`, so retention scans that directory
207
+ // directly. Also schedule the old artifact root so pre-P2-B direct
208
+ // `.vision-run-*` directories continue to age out safely.
209
+ if (resolved.runsBaseReal) {
210
+ scheduleArtifactRetention(resolved.runsBaseReal, { protectRunId: resolved.runId })
211
+ }
212
+ scheduleArtifactRetention(resolved.artifactsBaseReal, { protectRunId: resolved.runId })
213
+ }
214
+ return resolved.target
215
+ } finally {
216
+ if (!published) {
217
+ try { await unlinkImpl(temp) } catch { /* best effort */ }
218
+ }
219
+ }
220
+ }
@@ -1,4 +1,4 @@
1
- import { lstat, readdir, rm } from 'node:fs/promises'
1
+ import { lstat, opendir, rm } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
4
  export const ARTIFACT_RUN_PREFIX = '.vision-run-'
@@ -6,26 +6,30 @@ export const DEFAULT_ARTIFACT_TTL_MS = 30 * 24 * 60 * 60 * 1000
6
6
  export const DEFAULT_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024 * 1024
7
7
  export const DEFAULT_ARTIFACT_MAX_RUNS = 512
8
8
  export const DEFAULT_ARTIFACT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000
9
+ export const DEFAULT_ARTIFACT_SCAN_MAX_ENTRIES = 8_192
10
+ export const DEFAULT_ARTIFACT_SCAN_MAX_DEPTH = 32
11
+ export const DEFAULT_ARTIFACT_SCAN_MAX_MS = 2_000
9
12
 
10
13
  const cleanupState = new Map()
14
+ const activeArtifactRuns = new Map()
11
15
  const MAX_TRACKED_ROOTS = 64
12
16
 
13
17
  export function isManagedArtifactRunName(name) {
14
18
  return typeof name === 'string' && /^\.vision-run-[A-Za-z0-9._-]+$/.test(name)
15
19
  }
16
20
 
17
- async function treeBytes(target) {
18
- let info
19
- try { info = await lstat(target) } catch { return 0 }
20
- if (info.isSymbolicLink()) return 0
21
- if (!info.isDirectory()) return Number(info.size) || 0
22
- let total = 0
23
- let entries
24
- try { entries = await readdir(target, { withFileTypes: true }) } catch { return 0 }
25
- for (const entry of entries) {
26
- total += await treeBytes(path.join(target, entry.name))
21
+ /** Keep one run protected for the entire vision-tool lifetime. */
22
+ export function retainArtifactRun(runId) {
23
+ if (!isManagedArtifactRunName(runId)) return () => {}
24
+ activeArtifactRuns.set(runId, (activeArtifactRuns.get(runId) ?? 0) + 1)
25
+ let released = false
26
+ return () => {
27
+ if (released) return
28
+ released = true
29
+ const remaining = (activeArtifactRuns.get(runId) ?? 1) - 1
30
+ if (remaining <= 0) activeArtifactRuns.delete(runId)
31
+ else activeArtifactRuns.set(runId, remaining)
27
32
  }
28
- return total
29
33
  }
30
34
 
31
35
  function finitePositive(value, fallback) {
@@ -33,69 +37,307 @@ function finitePositive(value, fallback) {
33
37
  return Number.isFinite(number) && number > 0 ? number : fallback
34
38
  }
35
39
 
40
+ function finitePositiveInteger(value, fallback) {
41
+ return Math.max(1, Math.floor(finitePositive(value, fallback)))
42
+ }
43
+
44
+ function createScanBudget({ maxEntries, maxDepth, maxMs, clock }) {
45
+ const startedAt = Number(clock())
46
+ return {
47
+ maxEntries: finitePositiveInteger(maxEntries, DEFAULT_ARTIFACT_SCAN_MAX_ENTRIES),
48
+ maxDepth: finitePositiveInteger(maxDepth, DEFAULT_ARTIFACT_SCAN_MAX_DEPTH),
49
+ maxMs: finitePositive(maxMs, DEFAULT_ARTIFACT_SCAN_MAX_MS),
50
+ clock,
51
+ startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
52
+ entries: 0,
53
+ limited: false,
54
+ exhausted: false,
55
+ }
56
+ }
57
+
58
+ function createScanBudgets(options = {}) {
59
+ const clock = typeof options.scanClock === 'function' ? options.scanClock : Date.now
60
+ return {
61
+ discovery: createScanBudget({
62
+ maxEntries: options.maxDiscoveryEntries ?? options.maxScanEntries,
63
+ maxDepth: 1,
64
+ maxMs: options.maxDiscoveryMs ?? options.maxScanMs,
65
+ clock,
66
+ }),
67
+ measurement: createScanBudget({
68
+ maxEntries: options.maxScanEntries,
69
+ maxDepth: options.maxScanDepth,
70
+ maxMs: options.maxScanMs,
71
+ clock,
72
+ }),
73
+ }
74
+ }
75
+
76
+ function scanTimedOut(budget) {
77
+ const current = Number(budget.clock())
78
+ if (!Number.isFinite(current)) return false
79
+ return current - budget.startedAt >= budget.maxMs
80
+ }
81
+
82
+ function admitScanEntry(budget) {
83
+ if (budget.exhausted) return false
84
+ if (budget.entries >= budget.maxEntries || scanTimedOut(budget)) {
85
+ budget.exhausted = true
86
+ budget.limited = true
87
+ return false
88
+ }
89
+ budget.entries += 1
90
+ return true
91
+ }
92
+
93
+ function protectedRunSet(options = {}) {
94
+ const shared = options.protectRunIds instanceof Set
95
+ ? options.protectRunIds
96
+ : new Set(Array.isArray(options.protectRunIds) ? options.protectRunIds : [])
97
+ if (isManagedArtifactRunName(options.protectRunId)) shared.add(options.protectRunId)
98
+ return shared
99
+ }
100
+
101
+ function runProtected(name, protectedRuns) {
102
+ return protectedRuns.has(name) || activeArtifactRuns.has(name)
103
+ }
104
+
105
+ async function collectManagedRuns(root, budget) {
106
+ let directory
107
+ try {
108
+ directory = await opendir(root)
109
+ } catch (error) {
110
+ const missing = error?.code === 'ENOENT' || error?.code === 'ENOTDIR'
111
+ if (!missing) budget.limited = true
112
+ return { runs: [], complete: missing }
113
+ }
114
+ const runs = []
115
+ let complete = true
116
+ try {
117
+ for await (const entry of directory) {
118
+ if (!admitScanEntry(budget)) {
119
+ complete = false
120
+ break
121
+ }
122
+ if (!entry.isDirectory() || !isManagedArtifactRunName(entry.name)) continue
123
+ const target = path.join(root, entry.name)
124
+ let info
125
+ try {
126
+ info = await lstat(target)
127
+ } catch {
128
+ budget.limited = true
129
+ complete = false
130
+ continue
131
+ }
132
+ if (!info.isDirectory() || info.isSymbolicLink()) continue
133
+ runs.push({
134
+ name: entry.name,
135
+ target,
136
+ mtimeMs: Number(info.mtimeMs) || 0,
137
+ bytes: 0,
138
+ scanComplete: false,
139
+ })
140
+ }
141
+ } catch {
142
+ budget.limited = true
143
+ complete = false
144
+ }
145
+ return { runs, complete }
146
+ }
147
+
148
+ /**
149
+ * Measure one managed run with a bounded streaming traversal. `complete=false`
150
+ * means the size is unknown; callers must never reinterpret uncertainty as
151
+ * evidence that destructive byte eviction is safe.
152
+ */
153
+ async function treeBytesBounded(target, budget, byteCeiling) {
154
+ const stack = [{ target, depth: 0 }]
155
+ let total = 0
156
+ let complete = true
157
+
158
+ while (stack.length > 0) {
159
+ if (budget.exhausted || scanTimedOut(budget)) {
160
+ budget.exhausted = true
161
+ budget.limited = true
162
+ return { bytes: total, complete: false }
163
+ }
164
+ const current = stack.pop()
165
+ let directory
166
+ try {
167
+ directory = await opendir(current.target)
168
+ } catch {
169
+ budget.limited = true
170
+ return { bytes: total, complete: false }
171
+ }
172
+
173
+ try {
174
+ for await (const entry of directory) {
175
+ if (!admitScanEntry(budget)) return { bytes: total, complete: false }
176
+ if (entry.isSymbolicLink()) continue
177
+ const child = path.join(current.target, entry.name)
178
+ let info
179
+ try {
180
+ info = await lstat(child)
181
+ } catch {
182
+ budget.limited = true
183
+ complete = false
184
+ continue
185
+ }
186
+ if (info.isSymbolicLink()) continue
187
+ if (info.isDirectory()) {
188
+ const nextDepth = current.depth + 1
189
+ if (nextDepth > budget.maxDepth) {
190
+ complete = false
191
+ budget.limited = true
192
+ continue
193
+ }
194
+ stack.push({ target: child, depth: nextDepth })
195
+ continue
196
+ }
197
+ total += Math.max(0, Number(info.size) || 0)
198
+ if (total > byteCeiling) {
199
+ return { bytes: byteCeiling + 1, complete: true, saturated: true }
200
+ }
201
+ }
202
+ } catch {
203
+ complete = false
204
+ budget.limited = true
205
+ }
206
+ }
207
+
208
+ return { bytes: total, complete }
209
+ }
210
+
211
+ function cleanupResult(scanned, removed, bytes, scan, includeScanDiagnostics) {
212
+ const result = { scanned, removed, bytes: Math.max(0, bytes) }
213
+ if (includeScanDiagnostics === true) {
214
+ result.scan = {
215
+ entries: scan.discovery.entries + scan.measurement.entries,
216
+ limited: scan.discovery.limited === true || scan.measurement.limited === true,
217
+ exhausted: scan.discovery.exhausted === true || scan.measurement.exhausted === true,
218
+ discoveryEntries: scan.discovery.entries,
219
+ measurementEntries: scan.measurement.entries,
220
+ discoveryComplete: scan.discoveryComplete === true,
221
+ bytesComplete: scan.bytesComplete === true,
222
+ }
223
+ }
224
+ return result
225
+ }
226
+
36
227
  /**
37
228
  * Delete only Vision Router run directories. Unknown files, legacy artifacts,
38
229
  * symlinks and user-created directories are intentionally outside this policy.
230
+ *
231
+ * TTL can act on each discovered run independently. maxRuns and maxBytes are
232
+ * global policies, so they run only when discovery is complete; byte eviction
233
+ * additionally requires every surviving run size to be known. Uncertainty is
234
+ * therefore fail-safe for user data rather than being treated as over-budget.
39
235
  */
40
236
  export async function cleanupArtifactRuns(root, options = {}) {
41
237
  const ttlMs = finitePositive(options.ttlMs, DEFAULT_ARTIFACT_TTL_MS)
42
238
  const maxBytes = finitePositive(options.maxBytes, DEFAULT_ARTIFACT_MAX_BYTES)
43
- const maxRuns = Math.max(1, Math.floor(finitePositive(options.maxRuns, DEFAULT_ARTIFACT_MAX_RUNS)))
239
+ const maxRuns = finitePositiveInteger(options.maxRuns, DEFAULT_ARTIFACT_MAX_RUNS)
44
240
  const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now()
45
- const protect = typeof options.protectRunId === 'string' ? options.protectRunId : undefined
241
+ const protectedRuns = protectedRunSet(options)
242
+ const budgets = createScanBudgets(options)
46
243
 
47
- let entries
48
- try { entries = await readdir(root, { withFileTypes: true }) } catch { return { scanned: 0, removed: 0, bytes: 0 } }
49
- const runs = []
50
- for (const entry of entries) {
51
- if (!entry.isDirectory() || !isManagedArtifactRunName(entry.name)) continue
52
- const target = path.join(root, entry.name)
53
- let info
54
- try { info = await lstat(target) } catch { continue }
55
- if (!info.isDirectory() || info.isSymbolicLink()) continue
56
- runs.push({
57
- name: entry.name,
58
- target,
59
- mtimeMs: Number(info.mtimeMs) || 0,
60
- bytes: await treeBytes(target),
61
- protected: entry.name === protect,
62
- })
244
+ const discovery = await collectManagedRuns(root, budgets.discovery)
245
+ const runs = discovery.runs
246
+ let bytesComplete = discovery.complete
247
+ if (runs.length === 0) {
248
+ return cleanupResult(0, 0, 0, {
249
+ ...budgets,
250
+ discoveryComplete: discovery.complete,
251
+ bytesComplete,
252
+ }, options.includeScanDiagnostics)
63
253
  }
64
254
 
65
255
  runs.sort((a, b) => b.mtimeMs - a.mtimeMs)
66
256
  const remove = new Set()
257
+ const byteEvictions = new Set()
67
258
  for (const run of runs) {
68
- if (!run.protected && now - run.mtimeMs >= ttlMs) remove.add(run)
259
+ if (!runProtected(run.name, protectedRuns) && now - run.mtimeMs >= ttlMs) remove.add(run)
69
260
  }
70
261
 
71
262
  const survivors = () => runs.filter((run) => !remove.has(run))
72
263
  let live = survivors()
73
- while (live.length > maxRuns) {
74
- const candidate = [...live].reverse().find((run) => !run.protected)
75
- if (!candidate) break
76
- remove.add(candidate)
77
- live = survivors()
264
+ if (discovery.complete) {
265
+ while (live.length > maxRuns) {
266
+ const candidate = [...live].reverse().find((run) => !runProtected(run.name, protectedRuns))
267
+ if (!candidate) break
268
+ remove.add(candidate)
269
+ live = survivors()
270
+ }
78
271
  }
79
272
 
80
- let totalBytes = live.reduce((sum, run) => sum + run.bytes, 0)
81
- while (totalBytes > maxBytes) {
82
- const candidate = [...live].reverse().find((run) => !run.protected)
83
- if (!candidate) break
84
- remove.add(candidate)
85
- totalBytes -= candidate.bytes
86
- live = survivors()
273
+ let totalBytes = 0
274
+ if (discovery.complete) {
275
+ for (const run of live) {
276
+ const measured = await treeBytesBounded(run.target, budgets.measurement, maxBytes)
277
+ run.bytes = measured.bytes
278
+ run.scanComplete = measured.complete === true
279
+ if (!run.scanComplete) bytesComplete = false
280
+ }
281
+ totalBytes = live.reduce((sum, run) => sum + run.bytes, 0)
282
+
283
+ if (bytesComplete) {
284
+ while (totalBytes > maxBytes) {
285
+ const candidate = [...live].reverse().find((run) => !runProtected(run.name, protectedRuns))
286
+ if (!candidate) break
287
+ remove.add(candidate)
288
+ byteEvictions.add(candidate)
289
+ totalBytes -= candidate.bytes
290
+ live = survivors()
291
+ }
292
+ }
87
293
  }
88
294
 
89
295
  let removed = 0
90
296
  for (const run of remove) {
297
+ // Protection is checked again immediately before deletion so a concurrent
298
+ // tool that became active after cleanup started cannot lose its run.
299
+ if (runProtected(run.name, protectedRuns)) {
300
+ if (byteEvictions.has(run)) totalBytes += run.bytes
301
+ else bytesComplete = false
302
+ continue
303
+ }
91
304
  try {
92
305
  await rm(run.target, { recursive: true, force: true })
93
306
  removed += 1
94
307
  } catch {
308
+ if (byteEvictions.has(run)) totalBytes += run.bytes
309
+ else bytesComplete = false
95
310
  // Retention is best-effort and must never make the foreground tool fail.
96
311
  }
97
312
  }
98
- return { scanned: runs.length, removed, bytes: Math.max(0, totalBytes) }
313
+
314
+ return cleanupResult(runs.length, removed, totalBytes, {
315
+ ...budgets,
316
+ discoveryComplete: discovery.complete,
317
+ bytesComplete,
318
+ }, options.includeScanDiagnostics)
319
+ }
320
+
321
+ function addScheduledProtection(state, options = {}) {
322
+ if (isManagedArtifactRunName(options.protectRunId)) state.protectedRunIds.add(options.protectRunId)
323
+ if (options.protectRunIds instanceof Set || Array.isArray(options.protectRunIds)) {
324
+ for (const runId of options.protectRunIds) {
325
+ if (isManagedArtifactRunName(runId)) state.protectedRunIds.add(runId)
326
+ }
327
+ }
328
+ }
329
+
330
+ function trimCleanupState() {
331
+ while (cleanupState.size > MAX_TRACKED_ROOTS) {
332
+ let evicted
333
+ for (const [root, state] of cleanupState) {
334
+ if (state.promise) continue
335
+ evicted = root
336
+ break
337
+ }
338
+ if (evicted === undefined) break
339
+ cleanupState.delete(evicted)
340
+ }
99
341
  }
100
342
 
101
343
  /** Coalesce cleanup work per artifact root so foreground tools only schedule it. */
@@ -104,21 +346,31 @@ export function scheduleArtifactRetention(root, options = {}) {
104
346
  const now = Date.now()
105
347
  let state = cleanupState.get(root)
106
348
  if (!state) {
107
- state = { lastStartedAt: 0, promise: undefined }
349
+ state = { lastStartedAt: 0, promise: undefined, protectedRunIds: new Set() }
108
350
  cleanupState.set(root, state)
109
351
  }
352
+
353
+ if (state.promise) {
354
+ addScheduledProtection(state, options)
355
+ return
356
+ }
357
+
110
358
  const intervalMs = finitePositive(options.intervalMs, DEFAULT_ARTIFACT_CLEANUP_INTERVAL_MS)
111
- if (state.promise || now - state.lastStartedAt < intervalMs) return
359
+ if (now - state.lastStartedAt < intervalMs) return
360
+
361
+ addScheduledProtection(state, options)
112
362
  state.lastStartedAt = now
113
- state.promise = cleanupArtifactRuns(root, options)
363
+ state.promise = cleanupArtifactRuns(root, {
364
+ ...options,
365
+ protectRunIds: state.protectedRunIds,
366
+ })
114
367
  .catch(() => undefined)
115
- .finally(() => { state.promise = undefined })
368
+ .finally(() => {
369
+ state.promise = undefined
370
+ state.protectedRunIds.clear()
371
+ })
116
372
 
117
373
  cleanupState.delete(root)
118
374
  cleanupState.set(root, state)
119
- while (cleanupState.size > MAX_TRACKED_ROOTS) {
120
- const oldest = cleanupState.keys().next().value
121
- if (oldest === undefined) break
122
- cleanupState.delete(oldest)
123
- }
375
+ trimCleanupState()
124
376
  }
@@ -15,6 +15,7 @@ import {
15
15
  readResponseTextBounded,
16
16
  } from './http-body-limit.js'
17
17
  import { stripTrailingSlashes } from './string-normalization.js'
18
+ import { currentVisionProviderTransport } from './vision-provider-transport.js'
18
19
 
19
20
  export const CATALOG_ROUTING_CORRECTIONS = [
20
21
  {
@@ -254,7 +255,11 @@ export async function callAnthropicCompatible(provider, messages, options = {})
254
255
  const url = `${stripTrailingSlashes(provider.baseURL)}/v1/messages`
255
256
  let response
256
257
  try {
257
- response = await fetch(url, {
258
+ const transport = currentVisionProviderTransport()
259
+ const requestFetch = transport
260
+ ? (input, init) => transport.fetch(input, init, { providerName: provider.name, allowProxy: true })
261
+ : fetch
262
+ response = await requestFetch(url, {
258
263
  method: 'POST',
259
264
  headers,
260
265
  body: JSON.stringify(system === undefined ? body : { ...body, system }),