@miller-tech/uap 2.21.3 → 2.36.4

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 (328) hide show
  1. package/README.md +22 -10
  2. package/config/model-profiles/qwen38-coder390.json +46 -0
  3. package/config/model-profiles/qwen38.json +3 -2
  4. package/config/model-registry.json +110 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/benchmarks/paired/types.d.ts +2 -2
  7. package/dist/bin/cli.js +315 -20
  8. package/dist/bin/cli.js.map +1 -1
  9. package/dist/cli/attention.d.ts +6 -0
  10. package/dist/cli/attention.d.ts.map +1 -0
  11. package/dist/cli/attention.js +115 -0
  12. package/dist/cli/attention.js.map +1 -0
  13. package/dist/cli/bench-report.d.ts +82 -0
  14. package/dist/cli/bench-report.d.ts.map +1 -0
  15. package/dist/cli/bench-report.js +237 -0
  16. package/dist/cli/bench-report.js.map +1 -0
  17. package/dist/cli/clients.d.ts +6 -0
  18. package/dist/cli/clients.d.ts.map +1 -0
  19. package/dist/cli/clients.js +142 -0
  20. package/dist/cli/clients.js.map +1 -0
  21. package/dist/cli/dashboard-grants.d.ts +26 -0
  22. package/dist/cli/dashboard-grants.d.ts.map +1 -0
  23. package/dist/cli/dashboard-grants.js +69 -0
  24. package/dist/cli/dashboard-grants.js.map +1 -0
  25. package/dist/cli/dashboard-pace.d.ts +31 -0
  26. package/dist/cli/dashboard-pace.d.ts.map +1 -0
  27. package/dist/cli/dashboard-pace.js +42 -0
  28. package/dist/cli/dashboard-pace.js.map +1 -0
  29. package/dist/cli/dashboard-session.d.ts +25 -0
  30. package/dist/cli/dashboard-session.d.ts.map +1 -0
  31. package/dist/cli/dashboard-session.js +814 -0
  32. package/dist/cli/dashboard-session.js.map +1 -0
  33. package/dist/cli/dashboard-types.d.ts +18 -0
  34. package/dist/cli/dashboard-types.d.ts.map +1 -0
  35. package/dist/cli/dashboard-types.js +9 -0
  36. package/dist/cli/dashboard-types.js.map +1 -0
  37. package/dist/cli/dashboard.d.ts +2 -18
  38. package/dist/cli/dashboard.d.ts.map +1 -1
  39. package/dist/cli/dashboard.js +43 -793
  40. package/dist/cli/dashboard.js.map +1 -1
  41. package/dist/cli/deliver.d.ts +35 -0
  42. package/dist/cli/deliver.d.ts.map +1 -1
  43. package/dist/cli/deliver.js +93 -9
  44. package/dist/cli/deliver.js.map +1 -1
  45. package/dist/cli/diff.d.ts +85 -0
  46. package/dist/cli/diff.d.ts.map +1 -0
  47. package/dist/cli/diff.js +293 -0
  48. package/dist/cli/diff.js.map +1 -0
  49. package/dist/cli/eta.d.ts +35 -0
  50. package/dist/cli/eta.d.ts.map +1 -0
  51. package/dist/cli/eta.js +101 -0
  52. package/dist/cli/eta.js.map +1 -0
  53. package/dist/cli/handsfree.d.ts +7 -0
  54. package/dist/cli/handsfree.d.ts.map +1 -1
  55. package/dist/cli/handsfree.js +27 -0
  56. package/dist/cli/handsfree.js.map +1 -1
  57. package/dist/cli/hooks.d.ts.map +1 -1
  58. package/dist/cli/hooks.js +126 -5
  59. package/dist/cli/hooks.js.map +1 -1
  60. package/dist/cli/loops.d.ts +24 -5
  61. package/dist/cli/loops.d.ts.map +1 -1
  62. package/dist/cli/loops.js +92 -12
  63. package/dist/cli/loops.js.map +1 -1
  64. package/dist/cli/models.d.ts +62 -0
  65. package/dist/cli/models.d.ts.map +1 -0
  66. package/dist/cli/models.js +567 -0
  67. package/dist/cli/models.js.map +1 -0
  68. package/dist/cli/plan.d.ts +9 -0
  69. package/dist/cli/plan.d.ts.map +1 -1
  70. package/dist/cli/plan.js +57 -4
  71. package/dist/cli/plan.js.map +1 -1
  72. package/dist/cli/proxy-relay.d.ts +55 -0
  73. package/dist/cli/proxy-relay.d.ts.map +1 -0
  74. package/dist/cli/proxy-relay.js +116 -0
  75. package/dist/cli/proxy-relay.js.map +1 -0
  76. package/dist/cli/proxy.d.ts +4 -0
  77. package/dist/cli/proxy.d.ts.map +1 -1
  78. package/dist/cli/proxy.js +26 -1
  79. package/dist/cli/proxy.js.map +1 -1
  80. package/dist/cli/sandbox.d.ts +101 -4
  81. package/dist/cli/sandbox.d.ts.map +1 -1
  82. package/dist/cli/sandbox.js +393 -44
  83. package/dist/cli/sandbox.js.map +1 -1
  84. package/dist/cli/self.d.ts +13 -0
  85. package/dist/cli/self.d.ts.map +1 -0
  86. package/dist/cli/self.js +94 -0
  87. package/dist/cli/self.js.map +1 -0
  88. package/dist/cli/systemd-services.d.ts +2 -8
  89. package/dist/cli/systemd-services.d.ts.map +1 -1
  90. package/dist/cli/systemd-services.js +5 -20
  91. package/dist/cli/systemd-services.js.map +1 -1
  92. package/dist/cli/task.js +1 -1
  93. package/dist/cli/task.js.map +1 -1
  94. package/dist/cli/wizard-config.d.ts.map +1 -1
  95. package/dist/cli/wizard-config.js +31 -2
  96. package/dist/cli/wizard-config.js.map +1 -1
  97. package/dist/config/settings-registry.d.ts +1 -1
  98. package/dist/config/settings-registry.d.ts.map +1 -1
  99. package/dist/config/settings-registry.js +108 -0
  100. package/dist/config/settings-registry.js.map +1 -1
  101. package/dist/coordination/capability-router.js +2 -2
  102. package/dist/coordination/capability-router.js.map +1 -1
  103. package/dist/coordination/database.d.ts +13 -0
  104. package/dist/coordination/database.d.ts.map +1 -1
  105. package/dist/coordination/database.js +63 -7
  106. package/dist/coordination/database.js.map +1 -1
  107. package/dist/coordination/eta-inject.d.ts +32 -0
  108. package/dist/coordination/eta-inject.d.ts.map +1 -0
  109. package/dist/coordination/eta-inject.js +64 -0
  110. package/dist/coordination/eta-inject.js.map +1 -0
  111. package/dist/coordination/reactor.d.ts.map +1 -1
  112. package/dist/coordination/reactor.js +14 -0
  113. package/dist/coordination/reactor.js.map +1 -1
  114. package/dist/coordination/service.d.ts +18 -8
  115. package/dist/coordination/service.d.ts.map +1 -1
  116. package/dist/coordination/service.js +38 -28
  117. package/dist/coordination/service.js.map +1 -1
  118. package/dist/dashboard/client-registry.d.ts +163 -0
  119. package/dist/dashboard/client-registry.d.ts.map +1 -0
  120. package/dist/dashboard/client-registry.js +549 -0
  121. package/dist/dashboard/client-registry.js.map +1 -0
  122. package/dist/dashboard/controls.d.ts.map +1 -1
  123. package/dist/dashboard/controls.js +33 -4
  124. package/dist/dashboard/controls.js.map +1 -1
  125. package/dist/dashboard/data-seeder.d.ts +10 -7
  126. package/dist/dashboard/data-seeder.d.ts.map +1 -1
  127. package/dist/dashboard/data-seeder.js +31 -75
  128. package/dist/dashboard/data-seeder.js.map +1 -1
  129. package/dist/dashboard/data-service.d.ts +9 -0
  130. package/dist/dashboard/data-service.d.ts.map +1 -1
  131. package/dist/dashboard/data-service.js +32 -3
  132. package/dist/dashboard/data-service.js.map +1 -1
  133. package/dist/dashboard/placement-routes.d.ts +119 -0
  134. package/dist/dashboard/placement-routes.d.ts.map +1 -0
  135. package/dist/dashboard/placement-routes.js +331 -0
  136. package/dist/dashboard/placement-routes.js.map +1 -0
  137. package/dist/dashboard/server.d.ts.map +1 -1
  138. package/dist/dashboard/server.js +408 -2
  139. package/dist/dashboard/server.js.map +1 -1
  140. package/dist/delivery/attention.d.ts +130 -0
  141. package/dist/delivery/attention.d.ts.map +1 -0
  142. package/dist/delivery/attention.js +341 -0
  143. package/dist/delivery/attention.js.map +1 -0
  144. package/dist/delivery/decompose.d.ts +18 -0
  145. package/dist/delivery/decompose.d.ts.map +1 -1
  146. package/dist/delivery/decompose.js +22 -1
  147. package/dist/delivery/decompose.js.map +1 -1
  148. package/dist/delivery/eta-estimate.d.ts +111 -0
  149. package/dist/delivery/eta-estimate.d.ts.map +1 -0
  150. package/dist/delivery/eta-estimate.js +171 -0
  151. package/dist/delivery/eta-estimate.js.map +1 -0
  152. package/dist/delivery/eta.d.ts +23 -0
  153. package/dist/delivery/eta.d.ts.map +1 -0
  154. package/dist/delivery/eta.js +59 -0
  155. package/dist/delivery/eta.js.map +1 -0
  156. package/dist/delivery/orchestrated-mission.d.ts +10 -0
  157. package/dist/delivery/orchestrated-mission.d.ts.map +1 -1
  158. package/dist/delivery/orchestrated-mission.js +15 -1
  159. package/dist/delivery/orchestrated-mission.js.map +1 -1
  160. package/dist/delivery/plan-check.d.ts +7 -1
  161. package/dist/delivery/plan-check.d.ts.map +1 -1
  162. package/dist/delivery/plan-check.js +23 -2
  163. package/dist/delivery/plan-check.js.map +1 -1
  164. package/dist/delivery/run-state.d.ts +1 -1
  165. package/dist/delivery/run-state.d.ts.map +1 -1
  166. package/dist/delivery/run-state.js +8 -1
  167. package/dist/delivery/run-state.js.map +1 -1
  168. package/dist/delivery/task-orchestrator.d.ts +8 -3
  169. package/dist/delivery/task-orchestrator.d.ts.map +1 -1
  170. package/dist/delivery/task-orchestrator.js +75 -43
  171. package/dist/delivery/task-orchestrator.js.map +1 -1
  172. package/dist/delivery/task-sync.d.ts +21 -0
  173. package/dist/delivery/task-sync.d.ts.map +1 -1
  174. package/dist/delivery/task-sync.js +44 -8
  175. package/dist/delivery/task-sync.js.map +1 -1
  176. package/dist/delivery/task-workspace.d.ts +3 -3
  177. package/dist/delivery/task-workspace.js +3 -3
  178. package/dist/generators/self-card.d.ts +65 -0
  179. package/dist/generators/self-card.d.ts.map +1 -0
  180. package/dist/generators/self-card.js +256 -0
  181. package/dist/generators/self-card.js.map +1 -0
  182. package/dist/inference/probe.js +1 -1
  183. package/dist/inference/probe.js.map +1 -1
  184. package/dist/memory/prepopulate.d.ts.map +1 -1
  185. package/dist/memory/prepopulate.js +2 -0
  186. package/dist/memory/prepopulate.js.map +1 -1
  187. package/dist/models/analytics.d.ts +32 -0
  188. package/dist/models/analytics.d.ts.map +1 -1
  189. package/dist/models/analytics.js +68 -5
  190. package/dist/models/analytics.js.map +1 -1
  191. package/dist/models/plan-readability.d.ts +55 -0
  192. package/dist/models/plan-readability.d.ts.map +1 -0
  193. package/dist/models/plan-readability.js +339 -0
  194. package/dist/models/plan-readability.js.map +1 -0
  195. package/dist/placement/admission.d.ts +59 -0
  196. package/dist/placement/admission.d.ts.map +1 -0
  197. package/dist/placement/admission.js +197 -0
  198. package/dist/placement/admission.js.map +1 -0
  199. package/dist/placement/auto.d.ts +44 -0
  200. package/dist/placement/auto.d.ts.map +1 -0
  201. package/dist/placement/auto.js +212 -0
  202. package/dist/placement/auto.js.map +1 -0
  203. package/dist/placement/enforce.d.ts +128 -0
  204. package/dist/placement/enforce.d.ts.map +1 -0
  205. package/dist/placement/enforce.js +655 -0
  206. package/dist/placement/enforce.js.map +1 -0
  207. package/dist/placement/idle.d.ts +50 -0
  208. package/dist/placement/idle.d.ts.map +1 -0
  209. package/dist/placement/idle.js +213 -0
  210. package/dist/placement/idle.js.map +1 -0
  211. package/dist/placement/ledger.d.ts +323 -0
  212. package/dist/placement/ledger.d.ts.map +1 -0
  213. package/dist/placement/ledger.js +263 -0
  214. package/dist/placement/ledger.js.map +1 -0
  215. package/dist/placement/preview.d.ts +51 -0
  216. package/dist/placement/preview.d.ts.map +1 -0
  217. package/dist/placement/preview.js +61 -0
  218. package/dist/placement/preview.js.map +1 -0
  219. package/dist/placement/probes.d.ts +47 -0
  220. package/dist/placement/probes.d.ts.map +1 -0
  221. package/dist/placement/probes.js +207 -0
  222. package/dist/placement/probes.js.map +1 -0
  223. package/dist/placement/proxy-env.d.ts +21 -0
  224. package/dist/placement/proxy-env.d.ts.map +1 -0
  225. package/dist/placement/proxy-env.js +42 -0
  226. package/dist/placement/proxy-env.js.map +1 -0
  227. package/dist/placement/registry.d.ts +1038 -0
  228. package/dist/placement/registry.d.ts.map +1 -0
  229. package/dist/placement/registry.js +274 -0
  230. package/dist/placement/registry.js.map +1 -0
  231. package/dist/policies/liveness.d.ts +6 -8
  232. package/dist/policies/liveness.d.ts.map +1 -1
  233. package/dist/policies/liveness.js +6 -25
  234. package/dist/policies/liveness.js.map +1 -1
  235. package/dist/policies/sandbox-anchor.d.ts +91 -0
  236. package/dist/policies/sandbox-anchor.d.ts.map +1 -0
  237. package/dist/policies/sandbox-anchor.js +187 -0
  238. package/dist/policies/sandbox-anchor.js.map +1 -0
  239. package/dist/policies/sandbox-grants.d.ts +131 -0
  240. package/dist/policies/sandbox-grants.d.ts.map +1 -0
  241. package/dist/policies/sandbox-grants.js +562 -0
  242. package/dist/policies/sandbox-grants.js.map +1 -0
  243. package/dist/state/manifest.d.ts +4 -2
  244. package/dist/state/manifest.d.ts.map +1 -1
  245. package/dist/state/manifest.js +32 -16
  246. package/dist/state/manifest.js.map +1 -1
  247. package/dist/utils/checkout-root.d.ts +2 -0
  248. package/dist/utils/checkout-root.d.ts.map +1 -0
  249. package/dist/utils/checkout-root.js +43 -0
  250. package/dist/utils/checkout-root.js.map +1 -0
  251. package/dist/utils/resolve-uap-bin.d.ts +5 -0
  252. package/dist/utils/resolve-uap-bin.d.ts.map +1 -0
  253. package/dist/utils/resolve-uap-bin.js +33 -0
  254. package/dist/utils/resolve-uap-bin.js.map +1 -0
  255. package/dist/utils/terminal-safe.d.ts +12 -0
  256. package/dist/utils/terminal-safe.d.ts.map +1 -0
  257. package/dist/utils/terminal-safe.js +53 -0
  258. package/dist/utils/terminal-safe.js.map +1 -0
  259. package/docs/ATTRIBUTION.md +1 -0
  260. package/docs/DASHBOARD_UPLIFT_SPEC.md +8 -8
  261. package/docs/INDEX.md +6 -5
  262. package/docs/architecture/OVERVIEW.md +15 -3
  263. package/docs/architecture/PROTOCOL.md +7 -1
  264. package/docs/architecture/adr/0006-operator-directed-model-placement.md +86 -0
  265. package/docs/architecture/adr/0007-auto-displacement-allowlist-as-standing-consent.md +79 -0
  266. package/docs/architecture/adr/0008-fleet-clients-link-out.md +92 -0
  267. package/docs/architecture/adr/0009-completion-triggered-dispatch-and-per-leaf-model-dials.md +71 -0
  268. package/docs/architecture/adr/0010-attention-queue-and-afk-auto-decider.md +159 -0
  269. package/docs/architecture/adr/0011-sandbox-grants-and-network-egress-boundary.md +219 -0
  270. package/docs/architecture/adr/0012-operator-legibility-surfaces.md +175 -0
  271. package/docs/getting-started/INSTALLATION.md +1 -1
  272. package/docs/guides/AUTOMATIC_FEATURES.md +8 -0
  273. package/docs/guides/LOCAL_MODELS.md +1 -1
  274. package/docs/guides/PROXY.md +2 -1
  275. package/docs/guides/SANDBOX.md +91 -4
  276. package/docs/integrations/MCP_ROUTER.md +1 -1
  277. package/docs/plans/model-performance-proof-options.md +176 -0
  278. package/docs/reference/API.md +1 -1
  279. package/docs/reference/CLI.md +169 -4
  280. package/docs/reference/COMPARISON.md +1 -1
  281. package/docs/reference/CONFIGURATION.md +12 -3
  282. package/docs/reference/CONFIGURATION_REFERENCE.md +269 -14
  283. package/docs/reference/DATABASE_SCHEMA.md +1 -1
  284. package/docs/reference/FEATURES.md +15 -3
  285. package/docs/reference/PATTERNS.md +1 -1
  286. package/docs/reference/PLATFORMS.md +14 -1
  287. package/docs/specs/operator-model-placement.md +1314 -0
  288. package/docs/specs/operator-model-placement.review.md +339 -0
  289. package/package.json +2 -2
  290. package/scripts/maintenance/publish-npm.sh +23 -11
  291. package/scripts/version-bump.sh +6 -51
  292. package/src/policies/enforcers/__pycache__/_common.cpython-311.pyc +0 -0
  293. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-311.pyc +0 -0
  294. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-311.pyc +0 -0
  295. package/src/policies/schemas/policies/mandatory-testing-deployment.md +2 -5
  296. package/templates/hooks/__pycache__/deliver_autoroute.cpython-311.pyc +0 -0
  297. package/templates/hooks/session-start.sh +44 -0
  298. package/tools/agents/UAP/cli.py +14 -3
  299. package/tools/agents/UAP/paths.py +50 -0
  300. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-311.pyc +0 -0
  301. package/tools/agents/scripts/anthropic_proxy.py +1420 -100
  302. package/tools/agents/scripts/toolcall_path_normalizer.py +46 -1
  303. package/tools/agents/tests/test_anthropic_proxy_streaming.py +29 -6
  304. package/tools/agents/tests/test_doubling_break.py +64 -5
  305. package/tools/agents/tests/test_error_loop_break.py +10 -4
  306. package/tools/agents/tests/test_error_loop_ignores_correctives.py +13 -2
  307. package/tools/agents/tests/test_lockstep_escalation.py +280 -9
  308. package/tools/agents/tests/test_path_containment.py +94 -0
  309. package/tools/agents/tests/test_proxy_unix_socket.py +162 -0
  310. package/tools/agents/tests/test_signature_quality.py +400 -0
  311. package/tools/agents/tests/test_uap_compliance.py +11 -1
  312. package/web/dash/core.js +1 -0
  313. package/web/dash/tab-clients.js +196 -0
  314. package/web/dash/tab-models.js +387 -8
  315. package/web/dash/tab-policies.js +87 -4
  316. package/web/dash/tabs.js +77 -73
  317. package/web/dashboard.html +6 -0
  318. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  319. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
  320. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  321. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  322. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  323. package/web/dash/tab-agents.js +0 -21
  324. package/web/dash/tab-deliver.js +0 -12
  325. package/web/dash/tab-memory.js +0 -12
  326. package/web/dash/tab-orchestration.js +0 -21
  327. package/web/dash/tab-overview.js +0 -21
  328. package/web/dash/tab-tasks.js +0 -21
@@ -12,10 +12,17 @@ import { readFileSync, existsSync } from 'fs';
12
12
  import { join, dirname, sep } from 'path';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { getDashboardData, probeDatabaseHealth } from './data-service.js';
15
+ import { getPlacementState, getPlacementPending, getPlacementPreview, getPlacementAdmit } from './placement-routes.js';
16
+ import { resolvePendingPlacement, dismissPendingPlacement, loadPlacement, unloadPlacement } from '../placement/enforce.js';
17
+ import { autoPolicyPath, loadAutoPolicy, MIN_UNLOAD_IDLE_SECS, saveAutoPolicy } from '../placement/auto.js';
18
+ import { startIdleSweep } from '../placement/idle.js';
19
+ import { resolveAttentionBySource, sweepExpired } from '../delivery/attention.js';
15
20
  import { seedDashboardData, cleanupSeeder } from './data-seeder.js';
16
21
  import { getPolicyMemoryManager } from '../policies/policy-memory.js';
17
22
  import { heuristicOrder, buildOrderPrompt, parseOrderResponse } from '../policies/policy-order.js';
18
23
  import { readEventsSince, readRecentEvents } from '../utils/telemetry-store.js';
24
+ import { loadPolicy, runDoctor, worstHealth } from '../capacity/probe.js';
25
+ import { getClientSummaries, ensureClientDashboard, stopClientDashboard } from './client-registry.js';
19
26
  /** First prose sentence of a policy's markdown (its description), fail-soft. */
20
27
  function policyPromptDescription(md) {
21
28
  if (!md)
@@ -147,6 +154,14 @@ export function startDashboardServer(options = {}) {
147
154
  // Updated to the real bound port once the server is listening.
148
155
  let boundPort = requestedPort;
149
156
  const host = options.host || 'localhost';
157
+ // Idle-resident sweep handle (started in the listen callback once cwd's
158
+ // seeder has run; see §4.4.2). Stopped in close().
159
+ let idleSweeper = null;
160
+ // P3 attention decider handle — rides the SAME fleet-ownership guard as
161
+ // the idle sweeper (UAP_IDLE_SWEEP=0 marks a fleet-spawned CLIENT
162
+ // dashboard: exactly one sweeper may touch shared state). Stopped in
163
+ // close().
164
+ let attentionSweeper = null;
150
165
  // Precedence: explicit option (--refresh) > UAP_DASH_REFRESH_MS > 2000.
151
166
  // Floor 250ms — getDashboardData reads several DBs per snapshot, so a
152
167
  // too-small interval would peg the event loop. Ceiling 1h — past 2^31-1ms,
@@ -156,6 +171,9 @@ export function startDashboardServer(options = {}) {
156
171
  // Track SSE clients for live event streaming
157
172
  const sseClients = new Set();
158
173
  const cwd = process.cwd();
174
+ // In-flight /api/doctor run (probes block the loop; concurrent requests
175
+ // coalesce onto one sweep instead of serializing N of them).
176
+ let doctorInFlight = null;
159
177
  // Mutation auth (security audit D1): the policy-mutation POST routes disable
160
178
  // security controls (delivery-enforcement, self-protect) and persist the
161
179
  // change to the DB. Without a gate, any LAN host (--host 0.0.0.0) or any web
@@ -230,7 +248,13 @@ export function startDashboardServer(options = {}) {
230
248
  if (!isTokenBearingPage) {
231
249
  res.setHeader('Access-Control-Allow-Origin', '*');
232
250
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
233
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Uap-Dashboard-Token');
251
+ // The mutation-token header is deliberately NOT advertised in the
252
+ // preflight allow-list: the same-origin UI needs no CORS permission
253
+ // for it, and a wildcard `Access-Control-Allow-Headers` would let any
254
+ // origin's scripted preflight ride the token header name (security
255
+ // review — the mutation surface now includes process termination via
256
+ // /api/clients/stop). Reads stay open; mutations stay same-origin.
257
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
234
258
  }
235
259
  if (req.method === 'OPTIONS') {
236
260
  res.writeHead(204);
@@ -257,6 +281,146 @@ export function startDashboardServer(options = {}) {
257
281
  res.end(JSON.stringify(data));
258
282
  return;
259
283
  }
284
+ // API: Fleet of client folders — registry + auto-scan, with per-client
285
+ // fail-soft summaries (deliver runs, queue depth, git, health probe).
286
+ // Read-only (no token): identical authority to /api/dashboard, one
287
+ // client at a time. Method-guarded so a future POST /api/clients
288
+ // mutation can't silently fall through to this read handler instead
289
+ // of the token-gated control chokepoint.
290
+ if (url === '/api/clients' && req.method === 'GET') {
291
+ try {
292
+ const clients = await getClientSummaries(cwd, boundPort);
293
+ res.writeHead(200, { 'Content-Type': 'application/json' });
294
+ res.end(JSON.stringify({ clients }));
295
+ }
296
+ catch (err) {
297
+ res.writeHead(503, { 'Content-Type': 'application/json' });
298
+ res.end(JSON.stringify({ error: `client fleet unavailable: ${err.message}` }));
299
+ }
300
+ return;
301
+ }
302
+ // API: Model placement (spec §4.7) — READS ONLY in this phase; the
303
+ // load/unload/resolve mutation routes arrive with phase-3 enforcement
304
+ // and will go through mutationAuthorized(). `state` syncs the ledger
305
+ // (probes + unit reconcile) — heavier than the 2s dash refresh, so the
306
+ // tab throttles its own polling rather than riding the refresh tick.
307
+ if (url === '/api/placement/state') {
308
+ try {
309
+ const data = getPlacementState(cwd);
310
+ res.writeHead(200, { 'Content-Type': 'application/json' });
311
+ res.end(JSON.stringify(data));
312
+ }
313
+ catch (err) {
314
+ res.writeHead(503, { 'Content-Type': 'application/json' });
315
+ res.end(JSON.stringify({ error: `placement state unavailable: ${err.message}` }));
316
+ }
317
+ return;
318
+ }
319
+ if (url === '/api/placement/pending') {
320
+ const pending = getPlacementPending();
321
+ res.writeHead(200, { 'Content-Type': 'application/json' });
322
+ res.end(JSON.stringify({ pending }));
323
+ return;
324
+ }
325
+ // API: Placement admission (spec §4.3) — the proxy gate's controller
326
+ // endpoint. POST, and LOOPBACK-ONLY rather than mutationToken-gated:
327
+ // the caller is the proxy (a local process that has no dashboard
328
+ // token), and its answer parks or forwards live model traffic, so it
329
+ // must never be reachable from off-host. Non-loopback callers get 403
330
+ // before the body is even read.
331
+ if (url === '/api/placement/admit' && req.method === 'POST') {
332
+ // Peer address captured from the live socket only: no '' fallback —
333
+ // a destroyed socket reports undefined and must FAIL CLOSED here,
334
+ // not coerce into the allowlist (this server is TCP-only, so an
335
+ // empty peer is never a unix socket).
336
+ const peer = req.socket.remoteAddress;
337
+ const loopback = peer === '127.0.0.1' || peer === '::1' || peer === '::ffff:127.0.0.1';
338
+ if (!loopback) {
339
+ res.writeHead(403, { 'Content-Type': 'application/json' });
340
+ res.end(JSON.stringify({ error: 'placement admit is loopback-only' }));
341
+ return;
342
+ }
343
+ const body = await readBody(req);
344
+ const parsed = parseJsonBody(body);
345
+ const modelId = typeof parsed.model_id === 'string' ? parsed.model_id : '';
346
+ if (!modelId) {
347
+ res.writeHead(400, { 'Content-Type': 'application/json' });
348
+ res.end(JSON.stringify({ error: 'missing model_id' }));
349
+ return;
350
+ }
351
+ const answer = getPlacementAdmit(cwd, {
352
+ model_id: modelId,
353
+ client: typeof parsed.client === 'string' ? parsed.client : undefined,
354
+ session: typeof parsed.session === 'string' ? parsed.session : undefined,
355
+ // The request's context need — spec §4.4 cost is per-request.
356
+ // getPlacementAdmit sanitizes the shape, the route just forwards.
357
+ cells: typeof parsed.cells === 'number' ? parsed.cells : undefined,
358
+ });
359
+ res.writeHead(200, { 'Content-Type': 'application/json' });
360
+ res.end(JSON.stringify(answer));
361
+ return;
362
+ }
363
+ if (url.startsWith('/api/placement/preview')) {
364
+ const urlObj = new URL(url, `http://${host}:${boundPort}`);
365
+ const model = urlObj.searchParams.get('model');
366
+ if (!model) {
367
+ res.writeHead(400, { 'Content-Type': 'application/json' });
368
+ res.end(JSON.stringify({ error: 'missing ?model=<id>' }));
369
+ return;
370
+ }
371
+ const cells = Number(urlObj.searchParams.get('cells'));
372
+ try {
373
+ const preview = getPlacementPreview(cwd, model, {
374
+ cells: Number.isFinite(cells) && cells > 0 ? cells : undefined,
375
+ });
376
+ res.writeHead(200, { 'Content-Type': 'application/json' });
377
+ res.end(JSON.stringify(preview));
378
+ }
379
+ catch (err) {
380
+ res.writeHead(503, { 'Content-Type': 'application/json' });
381
+ res.end(JSON.stringify({ error: `placement preview unavailable: ${err.message}` }));
382
+ }
383
+ return;
384
+ }
385
+ // API: Capacity doctor (`uap doctor` as a read route). Same shape the
386
+ // CLI prints with --json (reportVersion pins it). On-demand: the
387
+ // Overview tab fetches it once per visit (plus a Refresh button) — the
388
+ // probes are execFileSync-bounded (5s cap each), so it must NOT ride
389
+ // the 2s snapshot tick. Concurrent requests COALESCE onto the in-flight
390
+ // run (the probes block the event loop; two tabs must not serialize
391
+ // two full probe sweeps). Fail-open shape: policy-load errors surface
392
+ // as an `error` field, never a 500 crash of the panel.
393
+ if (url === '/api/doctor') {
394
+ try {
395
+ if (!doctorInFlight) {
396
+ doctorInFlight = (async () => {
397
+ try {
398
+ const loaded = loadPolicy(cwd);
399
+ const services = runDoctor(loaded.policy);
400
+ return {
401
+ reportVersion: 1,
402
+ policy: loaded.path,
403
+ worst: worstHealth(services),
404
+ services,
405
+ };
406
+ }
407
+ catch (err) {
408
+ return { reportVersion: 1, error: err.message };
409
+ }
410
+ finally {
411
+ doctorInFlight = null;
412
+ }
413
+ })();
414
+ }
415
+ res.writeHead(200, { 'Content-Type': 'application/json' });
416
+ res.end(JSON.stringify(await doctorInFlight));
417
+ }
418
+ catch (err) {
419
+ res.writeHead(200, { 'Content-Type': 'application/json' });
420
+ res.end(JSON.stringify({ reportVersion: 1, error: err.message }));
421
+ }
422
+ return;
423
+ }
260
424
  // API: SSE event stream
261
425
  if (url === '/api/events') {
262
426
  res.writeHead(200, {
@@ -579,10 +743,54 @@ export function startDashboardServer(options = {}) {
579
743
  return false;
580
744
  }
581
745
  };
746
+ // Bind failure is the ONE error every deployment hits eventually (two
747
+ // projects, one 3847). Without this handler the process dies with a raw
748
+ // EADDRINUSE stack and a Restart=on-failure unit crash-loops against a
749
+ // port squatter. With it: say WHO owns the port (the fix depends on it —
750
+ // another UAP dashboard is a deliberate operator state, anything else is
751
+ // a genuine failure) and exit with a status systemd can act on.
752
+ // Attached to BOTH the http server AND the WebSocketServer: when ws wraps
753
+ // an existing server, the bind error surfaces on the WebSocketServer
754
+ // instance ('Emitted error event on WebSocketServer instance' — verified
755
+ // live), so an http-only handler never fires.
756
+ const onBindError = (err) => {
757
+ const die = (message, code) => {
758
+ console.error(message);
759
+ process.exit(code);
760
+ };
761
+ if (err.code !== 'EADDRINUSE') {
762
+ die(`UAP Dashboard: server error: ${err.message} — exiting`, 1);
763
+ return;
764
+ }
765
+ const probeUrl = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${requestedPort}/api/health`;
766
+ fetch(probeUrl, { signal: AbortSignal.timeout(1500) })
767
+ .then(async (r) => (r.ok ? (await r.json()) : null))
768
+ .then((body) => {
769
+ if (body?.service === 'uap-dashboard') {
770
+ // Exit 0 on purpose: the port's owner is a functioning UAP
771
+ // dashboard, so restarting cannot help — the operator must decide
772
+ // which project owns the port. A on-failure unit must NOT loop.
773
+ die(`UAP Dashboard: port ${requestedPort} is already owned by another UAP dashboard` +
774
+ ` (root: ${body.root ?? 'unknown'} — it serves THAT project's data, not this one: ${cwd}).`, 0);
775
+ return;
776
+ }
777
+ die(`UAP Dashboard: port ${requestedPort} is busy (not a UAP dashboard) — ` +
778
+ `free it or run with --port <n>. (${err.message})`, 1);
779
+ })
780
+ .catch(() => {
781
+ die(`UAP Dashboard: port ${requestedPort} is busy and did not answer a health probe — ` +
782
+ `free it or run with --port <n>. (${err.message})`, 1);
783
+ });
784
+ };
785
+ server.on('error', onBindError);
582
786
  const wss = new WebSocketServer({
583
787
  server,
584
788
  verifyClient: ({ origin }) => originAllowed(origin),
585
789
  });
790
+ // Bind errors surface HERE too (ws re-emits them on the WebSocketServer
791
+ // instance when it wraps the http server) — without this, a port conflict
792
+ // crashes with a raw stack instead of the owner report + exit contract.
793
+ wss.on('error', onBindError);
586
794
  const pushInterval = setInterval(async () => {
587
795
  if (wss.clients.size === 0 && sseClients.size === 0)
588
796
  return;
@@ -677,6 +885,36 @@ export function startDashboardServer(options = {}) {
677
885
  catch {
678
886
  /* seeder failure is non-fatal */
679
887
  }
888
+ // Idle-resident sweep (§4.4.2): this process already owns placement
889
+ // enforcement, so it owns the reverse too — unloading residents whose
890
+ // idle clock ran out. OFF unless the policy armed it (per-tick re-read:
891
+ // operator edits take effect without a restart). Fleet-spawned CLIENT
892
+ // dashboards set UAP_IDLE_SWEEP=0 so exactly one sweeper touches the
893
+ // shared ledger.
894
+ if (!['0', 'false', 'off'].includes((process.env.UAP_IDLE_SWEEP ?? '').toLowerCase())) {
895
+ idleSweeper = startIdleSweep(cwd, {
896
+ log: (m) => console.warn(m),
897
+ });
898
+ // P3: during a long unattended run — the exact AFK case this rail
899
+ // exists for — session starts are rare, and without a periodic
900
+ // sweep the decider would never fire between them while retry
901
+ // minted asks accumulate. 60s cadence: pending TTL is 120s and
902
+ // deadlines are pinned, so adoption lands within a minute of the
903
+ // real expiry. sweepExpired is fail-open and no-ops under
904
+ // UAP_ATTENTION_OFF.
905
+ attentionSweeper = setInterval(() => {
906
+ try {
907
+ const r = sweepExpired(cwd);
908
+ if (r.adopted + r.escalated > 0) {
909
+ console.warn(`[attention] sweep: ${r.adopted} auto-decided, ${r.escalated} escalated`);
910
+ }
911
+ }
912
+ catch {
913
+ /* the timer must never kill the dashboard */
914
+ }
915
+ }, 60_000);
916
+ attentionSweeper.unref();
917
+ }
680
918
  // Signal readiness with the actually-bound port (essential for port: 0).
681
919
  options.onListening?.({ port: boundPort, host });
682
920
  });
@@ -688,6 +926,16 @@ export function startDashboardServer(options = {}) {
688
926
  close: () => {
689
927
  clearInterval(eventPoller);
690
928
  clearInterval(pushInterval);
929
+ // Stop the idle-resident sweep before the rest of the teardown —
930
+ // a tick firing mid-close would race the server/ledger shutdown.
931
+ if (idleSweeper) {
932
+ idleSweeper.stop();
933
+ idleSweeper = null;
934
+ }
935
+ if (attentionSweeper) {
936
+ clearInterval(attentionSweeper);
937
+ attentionSweeper = null;
938
+ }
691
939
  // Cleanup seeder (clear heartbeat, mark agent completed)
692
940
  try {
693
941
  cleanupSeeder(cwd);
@@ -733,7 +981,7 @@ function parseJsonBody(body) {
733
981
  throw new Error('Invalid JSON in request body');
734
982
  }
735
983
  }
736
- const CONTROL_PREFIXES = ['/api/tasks', '/api/ledger', '/api/orchestrator', '/api/agents', '/api/deliver'];
984
+ const CONTROL_PREFIXES = ['/api/tasks', '/api/ledger', '/api/orchestrator', '/api/agents', '/api/deliver', '/api/placement', '/api/clients'];
737
985
  async function routeControl(url, cwd, body) {
738
986
  const seg = url.split('/').filter(Boolean); // ['api','tasks','<id>','update']
739
987
  const dec = (s) => decodeURIComponent(s);
@@ -768,6 +1016,164 @@ async function routeControl(url, cwd, body) {
768
1016
  return handleDeliverCancel(cwd, dec(seg[2]));
769
1017
  if (seg[1] === 'deliver' && seg.length === 4 && seg[3] === 'resume')
770
1018
  return handleDeliverResume(cwd, dec(seg[2]));
1019
+ // Placement mutations (spec §4.7): token-gated like every control route.
1020
+ // The result object is returned whole (200) even on ok:false — the steps
1021
+ // and rollback detail are the operator's evidence, not an error string.
1022
+ if (url === '/api/placement/resolve')
1023
+ return handlePlacementResolve(cwd, body);
1024
+ if (url === '/api/placement/dismiss')
1025
+ return handlePlacementDismiss(cwd, body);
1026
+ if (url === '/api/placement/load')
1027
+ return handlePlacementLoad(cwd, body);
1028
+ if (url === '/api/placement/unload')
1029
+ return handlePlacementUnload(cwd, body);
1030
+ if (url === '/api/placement/auto')
1031
+ return handlePlacementAuto(body);
1032
+ // Fleet spawn-on-demand (Option B link-out): bring up the selected client's
1033
+ // OWN dashboard server — full control stays on that instance, token-gated
1034
+ // and loopback-bound exactly like this one. Registry-only: the path is
1035
+ // resolved against listClients() inside, never trusted from the wire.
1036
+ if (url === '/api/clients/serve')
1037
+ return handleClientsServe(cwd, body);
1038
+ // Fleet teardown: stops the client's own dashboard instance (pid recorded
1039
+ // at spawn, ownership-verified before SIGTERM — see stopClientDashboard).
1040
+ if (url === '/api/clients/stop')
1041
+ return handleClientsStop(cwd, body);
771
1042
  return undefined;
772
1043
  }
1044
+ async function handleClientsServe(cwd, body) {
1045
+ const clientPath = typeof body.path === 'string' ? body.path : '';
1046
+ if (!clientPath)
1047
+ throw new Error('path is required');
1048
+ return ensureClientDashboard(cwd, clientPath);
1049
+ }
1050
+ async function handleClientsStop(cwd, body) {
1051
+ const clientPath = typeof body.path === 'string' ? body.path : '';
1052
+ if (!clientPath)
1053
+ throw new Error('path is required');
1054
+ return stopClientDashboard(cwd, clientPath);
1055
+ }
1056
+ async function handlePlacementResolve(cwd, body) {
1057
+ const placementId = typeof body.placement_id === 'string' ? body.placement_id : '';
1058
+ const option = Number(body.option);
1059
+ if (!placementId)
1060
+ throw new Error('placement_id is required');
1061
+ if (!Number.isInteger(option) || option < 1)
1062
+ throw new Error('option must be a 1-based index (as `uap models pending` prints them)');
1063
+ // Approval signature (spec §4.5): the dashboard UI sends the victim list
1064
+ // the operator actually SAW in the preview; enforcement refuses if the
1065
+ // recomputed option's victims differ.
1066
+ const expectedVictims = Array.isArray(body.expected_victims)
1067
+ ? body.expected_victims
1068
+ .filter((v) => typeof v === 'object' && v !== null &&
1069
+ typeof v.model === 'string' &&
1070
+ typeof v.config === 'string')
1071
+ .map((v) => ({ model: v.model, config: v.config }))
1072
+ : undefined;
1073
+ const result = await resolvePendingPlacement(cwd, placementId, option, expectedVictims ? { expectedVictims } : {});
1074
+ // P3: the operator answered through the PLACEMENT surface, not the queue —
1075
+ // close the attention ask by origin or a later sweep would record a false
1076
+ // AFK adoption over a decision that was actually made. Best-effort: no
1077
+ // open ask for this placement resolves nothing (idempotent).
1078
+ if (result.ok) {
1079
+ resolveAttentionBySource(cwd, `placement:${placementId}`, `operator applied option ${option} via the dashboard`);
1080
+ }
1081
+ return result;
1082
+ }
1083
+ function handlePlacementDismiss(cwd, body) {
1084
+ const placementId = typeof body.placement_id === 'string' ? body.placement_id : '';
1085
+ if (!placementId)
1086
+ throw new Error('placement_id is required');
1087
+ const { dismissed } = dismissPendingPlacement(placementId);
1088
+ if (dismissed) {
1089
+ resolveAttentionBySource(cwd, `placement:${placementId}`, 'operator dismissed the parked request via the dashboard');
1090
+ }
1091
+ return { dismissed };
1092
+ }
1093
+ async function handlePlacementLoad(cwd, body) {
1094
+ const model = typeof body.model === 'string' ? body.model : '';
1095
+ if (!model)
1096
+ throw new Error('model is required');
1097
+ return loadPlacement(cwd, model);
1098
+ }
1099
+ async function handlePlacementUnload(cwd, body) {
1100
+ const model = typeof body.model === 'string' ? body.model : '';
1101
+ if (!model)
1102
+ throw new Error('model is required');
1103
+ return unloadPlacement(cwd, model);
1104
+ }
1105
+ /** Phase-4 auto policy (§4.4.1) — the UI twin of `uap models auto`: status
1106
+ * read with no fields, enable/disable, and the displacement allowlist whose
1107
+ * additions require the same explicit `yes` consent the CLI's --yes demands
1108
+ * (what is being recorded is standing consent to evict unattended).
1109
+ * NO policy_path from the body (security review S1): an HTTP caller must not
1110
+ * redirect the policy write at an arbitrary filesystem path — the route always
1111
+ * writes the single authority (`autoPolicyPath()`, env-overridable for tests
1112
+ * via UAP_PLACEMENT_AUTO exactly like the CLI). */
1113
+ function handlePlacementAuto(body) {
1114
+ const stringList = (v) => Array.isArray(v) ? v.filter((m) => typeof m === 'string' && m.length > 0) : [];
1115
+ const enable = body.enable === true;
1116
+ const disable = body.disable === true;
1117
+ const allow = stringList(body.allow_displace);
1118
+ const disallow = stringList(body.disallow_displace);
1119
+ if (enable && disable)
1120
+ throw new Error('enable and disable are mutually exclusive');
1121
+ const path = autoPolicyPath();
1122
+ const allowUnload = stringList(body.allow_unload);
1123
+ const disallowUnload = stringList(body.disallow_unload);
1124
+ const idleSecs = body.unload_idle_after_secs;
1125
+ // `null` explicitly disarms the timer; a number arms/updates it. Anything
1126
+ // else in that field is a caller error, not a silent no-op.
1127
+ if (idleSecs !== undefined && idleSecs !== null && typeof idleSecs !== 'number') {
1128
+ throw new Error('unload_idle_after_secs must be a number of seconds, or null to disarm');
1129
+ }
1130
+ if (!enable && !disable && allow.length === 0 && disallow.length === 0 &&
1131
+ allowUnload.length === 0 && disallowUnload.length === 0 && idleSecs === undefined) {
1132
+ return loadAutoPolicy(path); // status: same shape the state payload serves
1133
+ }
1134
+ if (allow.length > 0 && body.yes !== true) {
1135
+ throw new Error('allow_displace records standing consent to UNLOAD the current resident(s) and load the requested ' +
1136
+ 'model without an operator prompt — confirm with yes: true (the same gate as ' +
1137
+ '`uap models auto --allow-displace <model> --yes`)');
1138
+ }
1139
+ if (allowUnload.length > 0 && body.yes !== true) {
1140
+ throw new Error('allow_unload records standing consent to STOP this model\'s resident automatically once it ' +
1141
+ 'goes idle — confirm with yes: true (the same gate as ' +
1142
+ '`uap models auto --allow-unload <model> --yes`)');
1143
+ }
1144
+ const prev = loadAutoPolicy(path);
1145
+ // Spread prev (NOT a literal rebuild): every field the route doesn't
1146
+ // know about must survive a save. The allowlists are cloned to fresh
1147
+ // arrays so the in-place filtering below never mutates `prev`.
1148
+ const allowDisplace = [...prev.allow_displace];
1149
+ const unloadAllow = [...(prev.unload_allow ?? [])];
1150
+ const touchesUnload = allowUnload.length > 0 || disallowUnload.length > 0 || prev.unload_allow !== undefined;
1151
+ // Old-shape policies keep their old-shape responses: unload_allow is only
1152
+ // (re)written when the caller touches it or it already exists on disk.
1153
+ const next = { ...prev, allow_displace: allowDisplace };
1154
+ if (enable)
1155
+ next.enabled = true;
1156
+ if (disable)
1157
+ next.enabled = false;
1158
+ for (const m of allow)
1159
+ if (!allowDisplace.includes(m))
1160
+ allowDisplace.push(m);
1161
+ next.allow_displace = allowDisplace.filter((m) => !disallow.includes(m));
1162
+ if (touchesUnload) {
1163
+ for (const m of allowUnload)
1164
+ if (!unloadAllow.includes(m))
1165
+ unloadAllow.push(m);
1166
+ next.unload_allow = unloadAllow.filter((m) => !disallowUnload.includes(m));
1167
+ }
1168
+ if (idleSecs === null)
1169
+ delete next.unload_idle_after_secs;
1170
+ else if (idleSecs !== undefined) {
1171
+ if (!Number.isFinite(idleSecs) || idleSecs < MIN_UNLOAD_IDLE_SECS) {
1172
+ throw new Error(`unload_idle_after_secs must be >= ${MIN_UNLOAD_IDLE_SECS} (under a minute the sweep would thrash)`);
1173
+ }
1174
+ next.unload_idle_after_secs = Math.floor(idleSecs);
1175
+ }
1176
+ saveAutoPolicy(next, path);
1177
+ return next;
1178
+ }
773
1179
  //# sourceMappingURL=server.js.map